Compare commits

...
Author SHA1 Message Date
weichen f7d9197011 Add template-driven document creation to the RDM duties tab
Why: the duties tab only listed existing documents — rdm.docs.instantiate
had no UI entry, so a fresh project showed an empty duties tab with no
setup path (owner trial feedback 2026-10-15).

Evidence: new docs-instantiate-params tests green; full vitest run shows
only the 12 pre-existing conversation-work failures also present at clean
HEAD; design-guard green.

Impact: duties tab gains a template picker + title input wired to
rdm.docs.instantiate (backend command rdm.docs.templates lists published
identities); instantiation refreshes the catalog and selects the new doc.

Refs: Agent-System-RDM docs/records/dhf/u4-header-footer-slots.md
2026-10-15 03:42:05 +00:00
weichen 5ab81432d1 Merge pull request 'Redesign the workbench visual hierarchy' (#87) from feature/workbench-visual-redesign into main 2026-10-14 16:53:45 +00:00
weichen 9b9f24b7be Enforce tokenized workbench visuals across themes
Why: Independent review found component-local color derivation, a private transition, and missing dark/molv coverage in the visual candidate.
Evidence: Frontend design guard passes and 19 Playwright scenarios cover light, dark, molv, and 320-1920px layouts.
Impact: Keeps the approved visual direction while moving color ownership to @ui tokens and removing private motion.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:50:17 +00:00
weichen 8dc68fd1e3 Keep the create action legible on dark navigation
Why: The inherited light-theme divider and hover treatment disappeared inside the new inverse create button.
Evidence: Focused browser layout test and production build pass locally.
Impact: Changes only the create action divider and hover surface.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:40:50 +00:00
weichen ddf3135c3f Establish a distinct workbench visual frame
Why: Spacing-only refinements still looked like the original flat gray workspace and did not satisfy the approved visual redesign.
Evidence: 272 unit tests, 16 browser scenarios across 320-1920px, and the production build pass locally.
Impact: Gives navigation, authored messages, assistant results, and work details clearly different visual surfaces while preserving behavior.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:38:26 +00:00
weichen b04f4ded95 Redesign the workbench visual hierarchy
Why: The three-column workspace read as flat and visually scattered, making conversation, current work, and navigation difficult to distinguish.
Evidence: 272 unit tests, 16 focused browser scenarios across 320-1920px, and the production build pass locally.
Impact: Refines the existing workbench presentation without changing its data flow or fast/slow task behavior.
Refs: Agent-System-Dev feature/workbench-visual-redesign
2026-10-14 16:35:17 +00:00
weichen 710ef836b5 Merge pull request 'Separate conversation history from work details' (#85) from feature/work-companion-visibility into main 2026-10-14 14:40:27 +00:00
weichen e10e447dab Keep collaboration events distinct from partial text
Why: Status words shared with a final reply could suppress a child completion record.

Evidence: 272 frontend unit tests, build, and 15 isolated browser scenarios.

Impact: Tag client-side collaboration replay so partial-delta deduplication preserves agent notes.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:27:28 +00:00
weichen b175506ebd Deduplicate partial streamed narration in process history
Why: A dropped delta batch can leave a short event prefix next to the complete saved message.

Evidence: 271 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Suppress overlapping partial replay text and thinking while retaining saved content.

Refs: Agent-System-Dev feature/work-companion-visibility UI1
2026-10-14 14:25:04 +00:00
weichen 7afae7f20b Preserve saved process details during event replay
Why: Event replay could hide saved retry notes and leave completed cards outside the active work filter.

Evidence: 270 frontend unit tests, build, and 14 isolated browser scenarios.

Impact: Merge replay into saved history, retain child progress, and locate cards under the right scope.

Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2
2026-10-14 14:17:33 +00:00
weichen 099a92022d Separate conversation history from work details
Why: Fast and slow channel cards duplicate across panes and completed replies hide process output.

Evidence: npm test; npm run build; 12 isolated Playwright scenarios with reload and responsive widths.

Impact: Preserve ordered folded process history, load durable run events, and give work cards one detailed pane.

Refs: Agent-System-Dev feature/work-companion-visibility UI1 UI2 UI3
2026-10-14 14:07:43 +00:00
weichen 5313cfad4e Merge pull request 'Add export controls with scoped asynchronous state' (#73) from feature/rdm-audit-export-entry into main 2026-10-14 02:49:17 +00:00
weichen 4e1eb39035 Integrate export controls with current identity boundaries
Why: Complete the authorized export entry while preserving current main project and session behavior.

Evidence: SELF full frontend tests and build, 263 unit tests; deferred browser races reproduced then all 6 passed. Raw records stay outside source until Dev task closure.

Impact: Export callbacks are invalidated on project, identity and unmount changes. Command readiness and current main security paths remain intact.

Refs: Dev PR #488; Frontend PR #73; user takeover authorization on 2026-10-14
2026-10-14 02:38:58 +00:00
weichen eb2d6d32f9 Merge pull request 'Use verified project identities for member workflows' (#82) from feature/project-member-access into main 2026-10-13 11:52:27 +00:00
weichenandCursor d9bb780384 Add an audit overview export that stays outside the readiness gate
Why: auditors need to generate and download a watermarked report from the overview, and that must not appear when the export commands are off or change whether the workspace itself opens.
Evidence: SELF npm test, 138 passed. Playwright download case is in e2e/rdm-audit.playwright.ts and runs under scripts/e2e.sh.
Impact: the overview shows type, generate, draft confirmation, watermark, sha256, expiry, and download only when the three export commands are enabled.
Refs: RDM pull request 301
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 15:38:00 +00:00
24 changed files with 1226 additions and 124 deletions
+20
View File
@@ -123,4 +123,24 @@ test.describe.serial('U4 项目追溯审计工作区', () => {
await page.locator('.rdm-audit-project option', { hasText: MAIN }).getAttribute('value') ?? '',
)
})
test('概览导出:生成后显示水位与校验和,并可下载', async ({ page }) => {
await login(page)
await openAudit(page)
const exportBox = page.locator('.rdm-audit-export')
await expect(exportBox).toBeVisible()
await exportBox.getByLabel('导出类型').selectOption('cgraph-docx')
await exportBox.getByRole('button', { name: '生成', exact: true }).click()
const draft = exportBox.getByRole('button', { name: '生成草稿' })
if (await draft.waitFor({ state: 'visible', timeout: 8_000 }).then(() => true).catch(() => false)) {
await draft.click()
}
await expect(exportBox.getByText(/水位 \d+/)).toBeVisible({ timeout: 15_000 })
await expect(exportBox.getByText(/sha256 [0-9a-f]{64}/)).toBeVisible()
await expect(exportBox.getByText(/有效期/)).toBeVisible()
const download = page.waitForEvent('download')
await exportBox.getByRole('button', { name: '下载' }).click()
const file = await download
expect(file.suggestedFilename()).toMatch(/\.docx$/)
})
})
+107
View File
@@ -0,0 +1,107 @@
// Isolated browser component regression: actual React workspace, deliberately deferred API responses.
// This checks client request ownership, not server authorization or business acceptance.
import { test, expect, type Page } from '@playwright/test'
test.skip(!process.env.PLAYWRIGHT_COMPONENT_BASE_URL, 'requires the isolated Vite component test server')
test.use({ baseURL: process.env.PLAYWRIGHT_COMPONENT_BASE_URL })
async function mount(page: Page, delayed: string) {
const index = await page.request.get('/')
const html = (await index.text()).replace(/<script[^>]+src="\/src\/main\.tsx[^" ]*"[^>]*><\/script>/, '')
// Let Vite prepare ReactDOM before requesting its optimized module directly.
await page.request.get('/src/main.tsx')
await page.route('**/__export_test', route => route.fulfill({ contentType: 'text/html',
body: html }))
await page.goto('/__export_test')
await page.waitForFunction(() => typeof (window as any).$RefreshReg$ === 'function')
await page.evaluate(async delayed => {
const load = (path: string) => import(/* @vite-ignore */ path)
const { default: React } = await load('/node_modules/.vite/deps/react.js')
const { default: ReactDOM } = await load('/node_modules/.vite/deps/react-dom_client.js')
const { RdmAuditWorkspace } = await load('/src/components/RdmAuditWorkspace.tsx')
const { api } = await load('/src/api.ts')
const { auth } = await load('/src/auth.ts')
const { store } = await load('/src/store.ts')
const { RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } = await load('/src/workspaces.ts')
const calls: { command: string; params: any }[] = []
let actor = 'alice'
const pending: (() => void)[] = []
let downloads = 0
URL.createObjectURL = () => { downloads++; return 'blob:export-test' }
URL.revokeObjectURL = () => {}
HTMLAnchorElement.prototype.click = () => {}
api.listPlugins = async () => []
api.listWorkspaces = async () => []
api.listCommands = async () => [...RDM_AUDIT_COMMANDS, ...RDM_EXPORT_COMMANDS].map(id => ({ id, enabled: true }))
api.adminZoneSections = async () => []
api.getSettings = async () => ({ user: {} })
api.rdmCommand = async (command: string, params: any = {}) => {
calls.push({ command, params })
const result = command === 'rdm.inspect.capabilities'
? { actor, crossProject: false, projects: ['A', 'B'].map(projectId => ({ projectId, name: actor + projectId, canAudit: true })) }
: command === 'rdm.inspect.summary'
? { projectId: params.projectId, counts: {}, watermark: 7 }
: command === 'rdm.inspect.export.create'
? { exportId: params.projectId + '-report', watermark: 7, sha256: 'abc', fileName: 'report.docx' }
: command === 'rdm.inspect.export.status'
? { expiresAt: '2099-01-01', watermark: 7, sha256: 'abc' }
: { fileName: 'report.docx', mime: 'application/octet-stream', dataB64: 'QQ==', sha256: 'abc' }
if (command === delayed && params.projectId === 'A') {
await new Promise<void>(resolve => pending.push(resolve))
}
return result
}
await store.refreshPlugins()
const root = ReactDOM.createRoot(document.getElementById('root')!)
root.render(React.createElement(RdmAuditWorkspace))
;(window as any).exportTest = { calls, release: () => pending.splice(0).forEach(resolve => resolve()),
downloads: () => downloads, identity: async () => { actor = 'bob'; auth.beginSession(); await store.refreshPlugins() },
unmount: () => root.unmount() }
}, delayed)
await expect(page.locator('.rdm-audit-export')).toBeVisible()
}
async function release(page: Page) {
await page.evaluate(async () => { (window as any).exportTest.release(); await new Promise(resolve => setTimeout(resolve, 0)) })
}
const generate = (page: Page) => page.getByRole('button', { name: '生成', exact: true }).click()
for (const stage of ['create', 'status']) {
test(`ignores old ${stage} response after project A -> B -> A`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.' + stage)
await generate(page)
await expect.poll(() => page.evaluate(stage => (window as any).exportTest.calls
.some((c: any) => c.command === 'rdm.inspect.export.' + stage), stage)).toBe(true)
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await page.getByLabel('选择项目').selectOption('A')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
await release(page)
await expect(page.getByRole('button', { name: '下载', exact: true })).toHaveCount(0)
if (stage === 'create') expect(await page.evaluate(() => (window as any).exportTest.calls
.filter((c: any) => c.command === 'rdm.inspect.export.status').length)).toBe(0)
await expect(page.getByRole('button', { name: '生成', exact: true })).toBeEnabled()
})
}
for (const change of ['project', 'identity', 'unmount']) {
test(`suppresses delayed download after ${change} change`, async ({ page }) => {
await mount(page, 'rdm.inspect.export.download')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
if (change === 'project') {
await page.getByLabel('选择项目').selectOption('B')
await expect(page.locator('.rdm-audit-export')).toBeVisible()
} else if (change === 'identity') {
await page.evaluate(() => (window as any).exportTest.identity())
await expect(page.getByLabel('选择项目').locator('option').first()).toHaveText('bobA')
} else await page.evaluate(() => (window as any).exportTest.unmount())
await release(page)
expect(await page.evaluate(() => (window as any).exportTest.downloads())).toBe(0)
})
}
test('current identity can generate and download normally', async ({ page }) => {
await mount(page, '')
await generate(page)
await page.getByRole('button', { name: '下载', exact: true }).click()
await expect.poll(() => page.evaluate(() => (window as any).exportTest.downloads())).toBe(1)
await expect(page.locator('.rdm-audit-export-result')).toContainText('水位 7')
})
+211 -11
View File
@@ -1,5 +1,5 @@
import { test, expect, type Page } from '@playwright/test'
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
import type { ActivityCard, Artifact, Conversation, Part, StreamEvent } from '../src/contracts'
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
@@ -21,7 +21,8 @@ const confirmation = work('confirm1', 'action_required', { kind: 'confirm', titl
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
})
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14,
history?: { events: StreamEvent[]; cardRef?: string; savedParts?: Part[] }, theme: 'light' | 'dark' | 'molv' = 'light') {
let cards = [...initialCards]
let actionCount = 0
let releaseAction: (() => void) | undefined
@@ -30,9 +31,9 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
let uploadNumber = 0
const errors: string[] = []
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(({ fontSize }) => {
await page.addInitScript(({ fontSize, theme }) => {
localStorage.setItem('as.conv', 'c1')
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
localStorage.setItem('as.ui', JSON.stringify({ theme, fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
const streams: { url: string; emit: (event: unknown) => void }[] = []
class TestEventSource {
onopen: (() => void) | null = null
@@ -48,7 +49,7 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
close() {}
}
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
}, { fontSize })
}, { fontSize, theme })
await page.route('**/api/**', async route => {
const request = route.request()
const url = new URL(request.url())
@@ -64,8 +65,21 @@ async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fo
body = { conversation: conversations.find(c => c.id === id), messages: id === 'c1' ? [
{ id: 'm1', conversationId: id, role: 'user', parts: [{ type: 'text', text: '请整理这三份材料,看看需要优先处理什么' }], createdAt: stamp },
{ id: 'm2', conversationId: id, role: 'assistant', parts: [{ type: 'text', text: '先核对时间安排,再补齐验收条件。其他内容仍在整理。' }], createdAt: stamp },
] : [], runs: [], activeHitl: [], lastSeq: 10 }
...(history ? [{ id: 'm3', conversationId: id, role: 'user', runId: 'run-history', parts: [{ type: 'text', text: '继续核对' }], createdAt: stamp },
{ id: 'm4', conversationId: id, role: 'assistant', runId: 'run-history', parts: history.savedParts ?? [
{ type: 'thinking', process: true, text: '先核对日期' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok', summary: '读取成功' } },
{ type: 'text', text: '最终回复已完成' },
...(history.cardRef ? [{ type: 'card', card: { cardId: history.cardRef, kind: 'result', data: [] } }] : []),
], createdAt: stamp }] : []),
] : [], runs: history ? [{ id: 'run-history', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'completed', createdAt: stamp, endedAt: stamp }] : [], activeHitl: [], lastSeq: 10 }
} else if (path === '/api/conversations/c1/cards') body = { cards }
else if (path === '/api/conversations/c1/runs/run-history/events') {
const after = Number(url.searchParams.get('after') ?? 0)
body = { events: (history?.events ?? []).filter(event => event.seq > after).slice(0, 500) }
}
else if (path === '/api/conversations/c2/cards') body = { cards: [] }
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
else if (path === '/api/projects/p1/artifacts') body = [artifact]
@@ -114,17 +128,105 @@ test('keeps real summary and details accessible with right pane closed', async (
expect(state.errors).toEqual([])
})
test('keeps one expanded ordinary summary when the desktop work pane opens', async ({ page }) => {
test('places the full work card only in the desktop work pane', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
await expect(page.locator('.right-panel .acard')).toHaveCount(1)
await expect(page.locator('[data-conversation-work] .acard')).toHaveCount(0)
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: /整理项目材料 · 查看摘要/ })).toBeVisible()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await expect(page.locator('.companion-topbar').getByRole('button', { name: '打开工作' })).toHaveCount(0)
await expect(page.locator('.right-panel').getByRole('button', { name: /工作/ }).first()).toBeVisible()
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
expect(state.errors).toEqual([])
})
test('uses a focused reading column and a compact authored-message shape', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 1080 })
const state = await fixture(page)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const conversation = page.locator('.chat-body .msg').first()
const authored = page.locator('.chat-body .msg.user').first()
const answer = page.locator('.chat-body .msg.assistant').first()
const [conversationBox, authoredBox, answerBox] = await Promise.all([
conversation.boundingBox(), authored.boundingBox(), answer.boundingBox(),
])
expect(conversationBox).not.toBeNull()
expect(authoredBox).not.toBeNull()
expect(answerBox).not.toBeNull()
expect(conversationBox!.width).toBeLessThanOrEqual(840)
expect(authoredBox!.width).toBeLessThanOrEqual(680)
expect(authoredBox!.x + authoredBox!.width).toBeCloseTo(answerBox!.x + answerBox!.width, 0)
const surfaces = await page.evaluate(() => {
const pick = (selector: string) => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return {
background: style.backgroundColor,
color: style.color,
borderRadius: Number.parseFloat(style.borderRadius),
paddingLeft: Number.parseFloat(style.paddingLeft),
}
}
return {
chrome: pick('.companion-topbar'),
context: pick('.left-nav'),
conversation: pick('.main'),
answer: pick('.msg.assistant'),
authored: pick('.msg.user .bubble'),
}
})
const rgb = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const distance = (a: string, b: string) => rgb(a).reduce((sum, value, index) => sum + Math.abs(value - rgb(b)[index]), 0)
expect(distance(surfaces.chrome.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.context.background, surfaces.conversation.background)).toBeGreaterThan(300)
expect(distance(surfaces.authored.background, surfaces.answer.background)).toBeGreaterThan(300)
expect(surfaces.answer.borderRadius).toBeGreaterThanOrEqual(12)
expect(surfaces.answer.paddingLeft).toBeGreaterThanOrEqual(20)
const activeCard = page.locator('.right-panel .acard.status-active')
await expect(activeCard).toBeVisible()
const cardStyle = await activeCard.evaluate(element => {
const style = getComputedStyle(element)
return { background: style.backgroundColor, borderLeftWidth: style.borderLeftWidth }
})
const panelBackground = await page.locator('.right-panel').evaluate(element => getComputedStyle(element).backgroundColor)
expect(cardStyle.background).not.toBe(panelBackground)
expect(Number.parseFloat(cardStyle.borderLeftWidth)).toBeGreaterThanOrEqual(3)
expect(state.errors).toEqual([])
})
for (const theme of ['light', 'dark', 'molv'] as const) {
test(`keeps the workbench frame legible in ${theme}`, async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const state = await fixture(page, [progress], 14, undefined, theme)
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const surfaces = await page.evaluate(() => ['.companion-topbar', '.left-nav', '.msg.user .bubble', '.msg.assistant'].map(selector => {
const element = document.querySelector(selector)
if (!element) throw new Error(`missing ${selector}`)
const style = getComputedStyle(element)
return { selector, foreground: style.color, background: style.backgroundColor }
}))
const channels = (value: string) => value.match(/[\d.]+/g)?.slice(0, 3).map(Number) ?? [0, 0, 0]
const luminance = (value: string) => {
const linear = channels(value).map(channel => {
const normalized = channel / 255
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
})
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
}
for (const surface of surfaces) {
const lighter = Math.max(luminance(surface.foreground), luminance(surface.background))
const darker = Math.min(luminance(surface.foreground), luminance(surface.background))
expect((lighter + 0.05) / (darker + 0.05), `${surface.selector} contrast in ${theme}`).toBeGreaterThanOrEqual(4.5)
}
expect(state.errors).toEqual([])
})
}
test('preserves the selected material, physical scroll and focus when background work finishes', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 960 })
const tabbed = work('work1', 'active', { body: [...progress.body!, {
@@ -160,19 +262,117 @@ test('preserves the selected material, physical scroll and focus when background
expect(state.errors).toEqual([])
})
test('shares submitting feedback between conversation and work confirmations', async ({ page }) => {
test('keeps one confirmation action location when the work pane is visible', async ({ page }) => {
const state = await fixture(page, [confirmation])
await page.getByRole('button', { name: '打开工作', exact: true }).click()
state.hold()
await page.locator('[data-conversation-work]').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('[data-conversation-work]')).toHaveCount(0)
await page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true }).click()
await expect(page.locator('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
await expect(page.locator('.chat-body').getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.actionCount).toBe(1)
state.release()
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
expect(state.errors).toEqual([])
})
test('keeps each run process folded and reloadable after the final reply', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'run-history', conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'thinking.delta', { index: 0, text: '先核对日期' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整工具输出' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复已完成' }),
]
const state = await fixture(page, [], 14, { events })
const message = page.locator('[data-msg-id="m4"]')
await expect(message.getByText('最终回复已完成')).toBeVisible()
await expect(message.getByText('开始读取材料')).toHaveCount(0)
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('先核对日期')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整工具输出')).toBeVisible()
await page.reload()
const restored = page.locator('[data-msg-id="m4"]')
await expect(restored.getByText('最终回复已完成')).toBeVisible()
await restored.getByRole('button', { name: /执行记录/ }).click()
await expect(restored.getByText('先核对日期')).toBeVisible()
await restored.locator('.tool-block-head').click()
await expect(restored.getByText('完整工具输出')).toBeVisible()
expect(state.errors).toEqual([])
})
test('replay enriches saved notes and keeps collaboration progress after completion', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
const events = [
event(1, 'message.delta', { index: 0, text: '<think>已解析的思考</think>' }),
event(2, 'message.delta', { index: 1, text: '开始读取' }),
event(3, 'tool.call', { callId: 'call-1', name: 'run_shell', arguments: '{"command":"read"}' }),
event(4, 'tool.output', { callId: 'call-1', text: '完整输出留存' }),
event(5, 'tool.result', { callId: 'call-1', status: 'ok', summary: '读取成功' }),
event(6, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(7, 'run.phase', { phase: 'finalizing' }),
event(8, 'message.delta', { index: 1, text: '最终回复已完成' }),
event(9, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(10, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
]
const savedParts: Part[] = [
{ type: 'text', process: true, text: '模型在输出前失败,正在重试' },
{ type: 'thinking', process: true, text: '已解析的思考' },
{ type: 'text', process: true, text: '开始读取材料' },
{ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'run_shell' } },
{ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } },
{ type: 'text', text: '最终回复已完成' },
]
await fixture(page, [], 14, { events, savedParts })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('模型在输出前失败,正在重试')).toBeVisible()
await expect(message.getByText('已解析的思考')).toBeVisible()
await expect(message.getByText('开始读取材料')).toBeVisible()
await expect(message.getByText('开始读取', { exact: true })).toHaveCount(0)
await expect(message.getByText('协作进展:正在核对附录')).toBeVisible()
await expect(message.getByText('协作结束:附录已核对')).toBeVisible()
await expect(message.getByText('<think>已解析的思考</think>')).toHaveCount(0)
await message.locator('.tool-block-head').click()
await expect(message.getByText('完整输出留存')).toBeVisible()
})
test('keeps a child completion note when the final reply uses the same status words', async ({ page }) => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'run-history'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: stamp, v: 1 })
await fixture(page, [], 14, { events: [
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '已完成' }),
event(4, 'agent.result', { agentRun: 'child', status: 'completed', summary: '已完成' }, 'child'),
], savedParts: [{ type: 'text', process: true, text: '记录已完成' }, { type: 'text', text: '已完成' }] })
const message = page.locator('[data-msg-id="m4"]')
await message.getByRole('button', { name: /执行记录/ }).click()
await expect(message.getByText('协作结束:已完成')).toBeVisible()
})
test('locates a completed card even when work is filtered to project pending items', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料核对结果',
body: [{ type: 'markdown', markdown: '完整结论在这里' }] })
await fixture(page, [result], 14, { events: [], cardRef: 'result1' })
await page.getByRole('button', { name: '打开工作', exact: true }).click()
const workPane = page.locator('.right-panel')
await workPane.locator('.inbox-scope').getByRole('button', { name: '项目' }).click()
await workPane.locator('.inbox-scope').getByRole('button', { name: '待处理' }).click()
await expect(workPane.getByText('完整结论在这里')).toHaveCount(0)
await page.locator('[data-msg-id="m4"] .card-anchor-locate').click()
await expect(workPane.getByText('完整结论在这里')).toBeVisible()
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '本会话' })).toHaveClass(/active/)
await expect(workPane.locator('.inbox-scope').getByRole('button', { name: '全部' })).toHaveClass(/active/)
})
test('opens the one file preview and preserves reading during background updates', async ({ page }) => {
const result = work('result1', 'done', { kind: 'result', title: '材料摘要已准备好', body: [{ type: 'doc-ref', docId: 'doc1' }] })
const state = await fixture(page, [result])
+4
View File
@@ -86,6 +86,10 @@ export const api = {
conversationDetail: (id: string) => auth.fetch(`${BASE}/api/conversations/${id}`).then(r =>
json<{ conversation: Conversation; messages: Message[]; runs: Run[]; activeHitl: HITLCard[]; lastSeq: number; resumeSeq?: number }>(r)),
runEventsPage: (convId: string, runId: string, after: number, signal?: AbortSignal) =>
auth.fetch(`${BASE}/api/conversations/${encodeURIComponent(convId)}/runs/${encodeURIComponent(runId)}/events?after=${after}&limit=500`, { signal })
.then(r => json<{ events: StreamEvent[] }>(r)),
sendMessage: (convId: string, text: string, attachmentIds: string[], requestId?: string) =>
auth.fetch(`${BASE}/api/conversations/${convId}/messages`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text, attachmentIds, requestId }) })
.then(async r => {
+99 -12
View File
@@ -3,8 +3,11 @@
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 12px;
padding: 12px 20px;
min-height: 64px;
padding: 12px 24px;
background: var(--bg-panel);
}
.work-companion-chat .chat-title { font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.025em; }
.work-companion-chat .chat-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.work-companion-chat .chat-header > .mk-col-root { position: relative; }
.work-companion-chat .chat-header > .mk-col-root > .mk-col {
@@ -36,9 +39,58 @@
.companion-setting select { max-width: 100%; padding: 4px 8px; background: var(--bg-panel); color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-caption); }
.companion-setting .session-note { flex: 1 1 100%; min-width: 0; width: 100%; }
.companion-setting-note { flex: 1 1 100%; margin: 0; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.6; }
.work-companion-chat .chat-body { padding: 24px clamp(16px, 5vw, 64px); }
.work-companion-chat .input-bar { padding: 8px clamp(16px, 5vw, 64px) 16px; }
.work-companion-chat .input-row { flex-direction: column; align-items: stretch; padding: 12px; box-shadow: none; }
.work-companion-chat .chat-body {
display: flex;
flex-direction: column;
align-items: center;
padding: 36px clamp(20px, 6vw, 96px) 48px;
scroll-padding-block: 36px;
}
.work-companion-chat .chat-body > * {
width: min(100%, 840px);
margin-left: auto;
margin-right: auto;
}
.work-companion-chat .chat-body > .msg { margin-bottom: 32px; }
.work-companion-chat .chat-body > .msg.user {
width: min(80%, 680px);
margin-right: max(0px, calc((100% - 840px) / 2));
margin-left: auto;
}
.work-companion-chat .chat-body > .msg.assistant {
padding: 20px 24px 16px;
border: 1px solid var(--border);
border-radius: var(--radius-xl);
background: var(--bg-raised);
box-shadow: var(--shadow-md);
}
.work-companion-chat .msg-role {
margin-bottom: 10px;
color: var(--text-dim);
font-size: var(--fs-ui-caption);
font-weight: 600;
}
.work-companion-chat .msg.user .msg-role { justify-content: flex-end; }
.work-companion-chat .msg.assistant .role-mark {
background: var(--primary);
border-color: var(--primary);
color: var(--primary-foreground);
}
.work-companion-chat .msg.assistant .part-text { color: var(--text); font-size: var(--fs-ui-base); line-height: 1.8; }
.work-companion-chat .input-bar {
padding: 12px clamp(20px, 6vw, 96px) 20px;
border-top: 1px solid var(--border-soft);
background: var(--bg-panel);
}
.work-companion-chat .input-bar > * { width: min(100%, 840px); margin-left: auto; margin-right: auto; }
.work-companion-chat .input-row {
flex-direction: column;
align-items: stretch;
padding: 14px;
border-color: var(--border-strong);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-md);
}
.work-companion-chat .input-row textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 4px; line-height: 1.6; font-size: var(--fs-ui-base); }
.companion-compose-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; min-width: 0; }
.companion-compose-spacer { flex: 1; }
@@ -47,7 +99,7 @@
.companion-add-material:focus-within { outline: 2px solid var(--accent); outline-offset: 2px; }
.companion-file-input { position: absolute; width: 1px; height: 1px; padding: 0; margin: 0; overflow: hidden; clip-path: inset(50%); }
.companion-input-hint { font-size: var(--fs-ui-sm); color: var(--text-dim); }
.companion-operation-trigger { margin-bottom: 8px; }
.companion-operation-trigger { margin-bottom: 8px; color: var(--text-dim); }
.companion-operation-body { padding: 12px; margin-bottom: 12px; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); }
.companion-operation-body .companion-setting { margin-bottom: 8px; }
.work-companion-chat .input-status { flex-wrap: wrap; gap: 4px 8px; padding: 8px 0; line-height: 1.5; }
@@ -57,28 +109,63 @@
.companion-remove-material { display: inline-flex; align-items: center; justify-content: center; padding: 4px; color: var(--text-dim); background: transparent; border: 0; border-radius: var(--radius-xs); cursor: pointer; }
.companion-remove-material:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .attach-chip { max-width: 100%; overflow-wrap: anywhere; }
.companion-empty { min-height: 240px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); }
.companion-empty { min-height: 320px; height: 100%; text-align: center; line-height: 1.7; }
.companion-empty .es-mark { width: 52px; height: 52px; margin-bottom: 8px; background: var(--bg-panel); box-shadow: var(--shadow-md); }
.companion-empty-title { color: var(--text); font-size: var(--fs-ui-xl); font-weight: 650; letter-spacing: -0.02em; }
.companion-examples { display: grid; gap: 8px; width: min(100%, 32rem); margin-top: 12px; }
.companion-example { padding: 12px 16px; background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-hover); }
.companion-example { padding: 14px 16px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-lg); color: var(--text-dim); font: inherit; text-align: left; cursor: pointer; }
.companion-example:hover { color: var(--text); background: var(--bg-raised); border-color: var(--border-strong); }
.conversation-work { margin: 24px 0 16px; min-width: 0; }
.conversation-work-heading { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 12px; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work-heading > span:first-child { color: var(--text); font-weight: 600; }
.conversation-work-heading > span:first-child { color: var(--text); font-size: var(--fs-ui-lg); font-weight: 650; }
.conversation-work-current, .conversation-work-history { display: grid; gap: 12px; min-width: 0; }
.conversation-work-more { padding: 12px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); }
.conversation-work .acard { margin: 0; }
.conversation-action-anchor { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 4px 12px; width: 100%; margin-top: 12px; padding: 10px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-panel); color: var(--text); text-align: left; font-size: var(--fs-ui-caption); cursor: pointer; }
.conversation-action-anchor > span:last-child { color: var(--accent-text); }
.conversation-action-anchor:hover { background: var(--bg-hover); }
.work-companion-chat .live-bar { flex-wrap: wrap; }
.work-companion-chat .part-error-detail, .work-companion-chat .hitl-detail { overflow-wrap: anywhere; white-space: pre-wrap; }
@media (max-width: 540px) {
.work-companion-chat .chat-header { padding: 12px 16px; gap: 8px; }
.work-companion-chat .chat-body { padding: 24px 16px 32px; }
.work-companion-chat .chat-body > .msg.user { width: 92%; margin-right: 0; }
.work-companion-chat .input-bar { padding: 10px 12px 14px; }
.companion-input-hint { display: none; }
.companion-empty { min-height: 200px; }
.work-companion-chat .hitl-choices { grid-template-columns: minmax(0, 1fr); }
}
.work-companion-chat .msg.user .bubble { background: var(--bg-active); }
.work-companion-chat .msg.user .bubble {
padding: 14px 18px;
background: var(--primary);
border: 1px solid var(--primary);
border-radius: var(--radius-lg) var(--radius-lg) var(--radius-xs) var(--radius-lg);
color: var(--primary-foreground);
line-height: 1.7;
box-shadow: var(--shadow-md);
}
.work-companion-chat .process-collapse {
margin: 12px 0 16px;
border-color: var(--border);
background: var(--bg-panel);
}
.work-companion-chat .process-collapse .process-collapse-summary { padding: 9px 14px; color: var(--text-dim); }
.work-companion-chat .process-collapse .process-collapse-summary:hover { background: var(--bg-hover); color: var(--text); }
.work-companion-chat .process-collapse .body { padding: 8px 14px 14px; border-top: 1px solid var(--border-soft); }
.companion-message-copy { padding: 4px 8px; border: 0; background: transparent; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; }
.companion-message-copy:hover { background: var(--bg-hover); color: var(--text); }
.companion-message-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 0; }
.companion-message-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 0;
opacity: 0;
}
.work-companion-chat .msg:hover .companion-message-actions,
.work-companion-chat .msg:focus-within .companion-message-actions { opacity: 1; }
.work-companion-chat .msg-tools { gap: 4px; }
@media (hover: none) {
.companion-message-actions { opacity: 1; }
}
+117 -22
View File
@@ -9,6 +9,20 @@
.companion-app .left-nav,
.companion-app .main,
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
.companion-app .left-nav,
.companion-app .companion-topbar {
--text: var(--workbench-chrome-text);
--text-dim: var(--workbench-chrome-dim);
--text-faint: var(--workbench-chrome-faint);
--bg-hover: var(--workbench-chrome-hover);
--bg-active: var(--workbench-chrome-active);
--border: var(--workbench-chrome-active);
--border-soft: var(--workbench-chrome-hover);
--border-strong: var(--workbench-chrome-dim);
background: var(--workbench-chrome);
color: var(--workbench-chrome-text);
border-color: var(--workbench-chrome-hover);
}
.companion-app .left-nav { transition: none; }
.companion-app .left-nav[hidden],
.companion-app .right-panel[hidden],
@@ -16,12 +30,58 @@
.companion-app .icon-only[hidden] { display: none !important; }
.shell-context-content { display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; }
.companion-app .companion-topbar { height: auto; min-height: 44px; gap: 8px; padding: 4px 12px; }
.companion-app .companion-topbar {
height: auto;
min-height: 52px;
gap: 12px;
padding: 8px 16px;
box-shadow: var(--shadow-md);
}
.topbar-identity { display: flex; align-items: center; gap: 8px; min-width: 0; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-caption); white-space: nowrap; }
.companion-topbar .topbar-name { font-size: var(--fs-ui-lg); font-weight: 650; letter-spacing: -0.02em; white-space: nowrap; }
.companion-app .right-panel .tabs {
min-height: 52px;
align-items: flex-end;
gap: 16px;
padding: 0 20px;
background: var(--bg-panel);
}
.companion-app .right-panel .tab {
min-height: 52px;
padding: 0 2px;
color: var(--text-dim);
font-size: var(--fs-ui-base);
font-weight: 600;
}
.companion-app .right-panel .tab:hover { color: var(--text); }
.companion-app .right-panel .tab-body { padding: 20px; }
.companion-app .right-panel .inbox-zone-label { margin: 20px 0 8px; color: var(--text); font-size: var(--fs-ui-caption); font-weight: 650; }
.companion-app .right-panel .acard { margin: 12px 0; border-color: var(--border); padding: 18px; }
.companion-app .right-panel .acard.status-active {
border-left: 3px solid var(--work-focus-border);
background: var(--work-focus-surface);
box-shadow: var(--shadow-md);
}
.companion-app .right-panel .acard-title { color: var(--text); font-size: var(--fs-ui-lg); line-height: 1.4; }
.companion-app .right-panel .acard-status {
display: inline-flex;
width: fit-content;
margin-top: 8px;
padding: 2px 8px;
border-radius: var(--radius-full);
background: var(--bg-active);
color: var(--text);
font-weight: 600;
}
.companion-app .right-panel .acard-body { margin-top: 16px; }
.companion-app .right-panel .acard-summary-row { grid-template-columns: minmax(64px, auto) minmax(0, 1fr); gap: 12px; margin: 12px 0; }
.companion-app .right-panel .acard-summary-row > span,
.companion-app .right-panel .acard-summary-label { color: var(--text-dim); font-weight: 600; }
.companion-app .right-panel .acard-summary-row p,
.companion-app .right-panel .acard-findings li { color: var(--text); font-size: var(--fs-ui-base); }
.companion-topbar .brand-mark {
background: var(--primary-sidebar);
color: var(--primary-sidebar-foreground);
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
border-radius: var(--radius-sm) !important;
}
@@ -37,39 +97,52 @@
border: none;
border-radius: var(--radius-sm);
background: none;
color: var(--text-sidebar-dim);
color: var(--workbench-chrome-dim);
font-size: var(--fs-ui-caption);
cursor: pointer;
white-space: nowrap;
}
.shell-nav-button:hover,
.shell-return:hover { background: var(--bg-hover); color: var(--text-sidebar); }
.shell-nav-button[aria-pressed='true'] { background: var(--bg-active); color: var(--text-sidebar); }
.shell-return:hover { background: var(--workbench-chrome-hover); color: var(--workbench-chrome-text); }
.shell-nav-button[aria-pressed='true'] { background: var(--workbench-chrome-active); color: var(--workbench-chrome-text); }
.shell-nav-button svg,
.shell-return svg { flex: none; }
.shell-back-icon { transform: rotate(180deg); }
.shell-pending {
padding: 0 4px;
border-radius: var(--radius-xs);
background: var(--bg-active);
color: var(--text-sidebar);
background: var(--workbench-chrome-active);
color: var(--workbench-chrome-text);
font-size: var(--fs-ui-xs);
font-variant-numeric: tabular-nums;
}
.companion-app .tsb-brand { gap: 4px; padding: 16px 12px 8px; }
.companion-app .tsb-projrow { min-height: 36px; height: auto; border-color: transparent; background: none; padding: 4px 8px; }
.companion-app .tsb-brand { gap: 8px; padding: 16px 14px 12px; border-bottom: 1px solid var(--border-soft); }
.companion-app .tsb-projrow { min-height: 40px; height: auto; border-color: transparent; background: none; padding: 6px 10px; }
.companion-app .tsb-projrow:hover { background: var(--bg-hover); }
.companion-app .tsb-projrow-name { flex: 1; }
.companion-app .tsb-projrow-name { flex: 1; font-size: var(--fs-ui-base); font-weight: 650; }
.companion-app .tsb-projrow-stage { max-width: 40%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); border-radius: var(--radius-sm); }
.companion-app .tsb-iconbtn { width: 32px; height: 32px; flex: none; }
.companion-app .tsb-actions { padding: 8px 12px 12px; gap: 12px; }
.companion-app .tsb-newtask { min-height: 40px; height: auto; box-shadow: none; transition: none; }
.companion-app .tsb-actions { padding: 12px 14px; gap: 10px; }
.companion-app .tsb-newtask {
min-height: 40px;
height: auto;
background: var(--workbench-chrome-text);
color: var(--workbench-chrome);
box-shadow: none;
transition: none;
}
.companion-app .tsb-newtask:hover { background: var(--workbench-chrome-text); }
.companion-app .tsb-newtask-main { justify-content: center; padding: 8px 12px; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; }
.companion-app .tsb-search { min-height: 36px; height: auto; padding: 8px 12px; }
.companion-app .tsb-orgrow { padding-bottom: 4px; }
.companion-app .tsb-task { min-height: 40px; height: auto; gap: 4px; transition: none; }
.companion-app .tsb-newtask-caret { width: 36px; flex: none; border-left-color: var(--workbench-chrome-inverse-border); }
.companion-app .tsb-newtask-caret:hover { background: var(--workbench-chrome-inverse-hover); }
.companion-app .tsb-search { min-height: 38px; height: auto; padding: 8px 12px; background: var(--workbench-chrome-hover); border-color: var(--workbench-chrome-hover); }
.companion-app .tsb-search:focus-within { background: var(--workbench-chrome-active); border-color: var(--workbench-chrome-dim); }
.companion-app .tsb-orgrow { padding: 4px 16px 8px; }
.companion-app .tsb-seclabel { color: var(--text-dim); font-size: var(--fs-ui-sm); font-weight: 600; }
.companion-app .tsb-scroll { padding: 0 8px 12px; }
.companion-app .tsb-task { min-height: 42px; height: auto; gap: 6px; padding: 0 10px; transition: none; }
.companion-app .tsb-task.active { background: var(--bg-active); color: var(--text); }
.companion-app .tsb-task-open {
display: flex;
align-items: center;
@@ -85,6 +158,7 @@
cursor: pointer;
}
.companion-app .tsb-task-open > svg { flex: none; }
.companion-app .tsb-task-t { font-size: var(--fs-ui-base); }
.companion-app .tsb-task-tm { color: var(--text-dim); }
.companion-app .tsb-task:focus-within .tsb-row-acts { display: inline-flex; }
.companion-app .tsb-task:focus-within .tsb-task-tm { display: none; }
@@ -103,11 +177,32 @@
cursor: pointer;
}
.companion-app .tsb-row-menu:hover { background: var(--bg-hover); color: var(--text); }
.companion-app .tsb-bottom { padding: 8px 12px; gap: 4px; }
.companion-app .tsb-tool { min-height: 36px; }
.companion-app .tsb-bottom { padding: 10px 12px; gap: 4px; background: var(--workbench-chrome-hover); }
.companion-app .tsb-tool { min-height: 38px; padding: 7px 10px; }
.companion-app .tsb-avatar { background: var(--bg-active); color: var(--text); box-shadow: none; border-radius: var(--radius-full); }
.companion-app .tsb-user-sub { color: var(--text-dim); }
.companion-app .tsb-user { min-height: 44px; height: auto; }
.companion-app .tsb-footer { padding: 10px 12px; }
.companion-app .tsb-user { min-height: 46px; height: auto; }
.companion-app .right-panel .inbox-scope {
gap: 4px;
margin: 14px 20px 0;
padding: 4px;
border: 1px solid var(--border-soft);
border-radius: var(--radius);
background: var(--bg);
}
.companion-app .right-panel .inbox-scope button {
flex: 1;
min-width: 0;
border: none;
border-radius: var(--radius-sm);
padding: 5px 10px;
opacity: 1;
color: var(--text-dim);
font-size: var(--fs-ui-sm);
white-space: nowrap;
}
.companion-app .right-panel .inbox-scope button.active { background: var(--bg-raised); box-shadow: var(--shadow-md); color: var(--text); }
.companion-app .tsb-menu {
min-width: min(216px, calc(100vw - 16px));
max-width: min(360px, calc(100vw - 16px));
@@ -116,7 +211,7 @@
}
.companion-app .tsb-mi { overflow-wrap: anywhere; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px 12px; }
.companion-app.compact .shell-navigation { width: 100%; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.companion-app.compact .shell-nav-button { min-width: 0; min-height: 40px; flex-wrap: wrap; gap: 0 4px; padding: 4px; }
.companion-app.compact .shell-nav-button > svg { display: none; }
+58 -16
View File
@@ -6,12 +6,12 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from '../api'
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
import { liveToMessage, livePhaseLabel, runEventsToTimeline, showProcessInline } from '../live'
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import type { Message, Part, HITLCard, ActivityCard, Artifact, StreamEvent } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
import { docBridge } from './Workbench'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { splitTimeline, mergeProcessTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
import { ConversationWork } from './ConversationWork'
import '../companion-conversation.css'
@@ -25,6 +25,7 @@ function hasFiles(dt: DataTransfer | null): boolean {
// Part 渲染器注册表:按类型查表;未知类型静默忽略(前向兼容)。
function PartView({ part, live }: { part: Part; live?: boolean | null }) {
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
switch (part.type) {
case 'text':
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
@@ -46,12 +47,12 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
case 'plan':
return null // 计划在右栏 Plan 标签呈现
case 'card': {
// 卡流锚点(D39):cardId 非空 = submit_card 引用部件——中栏自足(可展开全文)+ 跳收件箱。
// 右栏工作可见时保留定位入口;详细正文只在右栏。关闭右栏时中栏可展开全文。
if (part.card?.cardId) {
const ref = part.card.cardId
const card = findCard(ref)
const bodyChars = card?.body?.reduce((n, node) => n + (node.markdown?.length ?? 0), 0)
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={card}
return <CardAnchor title={card?.title ?? part.card.kind} chars={bodyChars} card={workPaneOwnsCards ? undefined : card}
onLocate={() => store.openInbox(ref)} docBridge={docBridge} />
}
// 插件内嵌卡片部件(cardlink 互定位,D32):点击 ↗ 定位收件箱对应卡(从 data 解析 card:<id>)。
@@ -197,6 +198,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
const autoCollapse = useStore(s => s.ui.autoCollapseProcess)
const ctxObs = useStore(s => (msg.runId ? s.contextObs[msg.runId] : undefined))
const run = useStore(s => (msg.runId ? s.runs.find(r => r.id === msg.runId) : undefined))
const childStamp = useStore(s => msg.runId ? s.runs.filter(r => r.parentRunId === msg.runId)
.map(r => `${r.id}:${r.status}:${r.endedAt ?? ''}`).join('|') : '')
const parts = msg.parts ?? []
useLiveClock(!!live)
const finished = !live && !!run && run.status !== 'running' && run.status !== 'pending' && run.status !== 'waiting_hitl'
@@ -211,6 +214,36 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
useEffect(() => {
if (!touchedProcess.has(memKey)) setOpen(showProcess)
}, [memKey, showProcess])
const [replayed, setReplayed] = useState<Part[] | null>(null)
const [processError, setProcessError] = useState(false)
useEffect(() => { setReplayed(null); setProcessError(false) }, [childStamp])
useEffect(() => {
if (!open) { setReplayed(null); setProcessError(false) }
}, [open])
useEffect(() => {
if (!open || live || !msg.runId || !convId || replayed || processError) return
const controller = new AbortController()
let active = true
void (async () => {
try {
const events: StreamEvent[] = []
let after = 0
while (true) {
const page = await api.runEventsPage(convId, msg.runId!, after, controller.signal)
const rows = page.events ?? []
events.push(...rows)
if (rows.length < 500) break
const next = rows[rows.length - 1].seq
if (next <= after) throw new Error('process event cursor did not advance')
after = next
}
if (active) setReplayed(runEventsToTimeline(events, msg.runId))
} catch {
if (active) setProcessError(true)
}
})()
return () => { active = false; controller.abort() }
}, [open, live, msg.runId, convId, replayed, processError])
const extras = live ? [] : citedArtifactCards(split.final)
const cards = live ? [] : dedupeDeliverables(split.final)
const lastText = (() => {
@@ -219,10 +252,11 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
})()
const streamingText = !!(live && live.phase === 'responding' && lastText >= 0
&& !live.items.some(it => it.kind === 'tool' && it.state !== 'done'))
const toolCount = split.timeline.filter(p => p.type === 'tool_call').length
const processParts = replayed === null ? split.timeline : mergeProcessTimeline(split.timeline, replayed, split.final)
const toolCount = processParts.filter(p => p.type === 'tool_call').length
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
const timeline = (
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
<ProcessTimeline parts={processParts} live={!!live} idKey={msg.id}
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
)
const toggleProcess = (next: boolean) => {
@@ -237,14 +271,18 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
<span>助手</span>
{live && <LiveBar live={live} />}
</div>
{split.timeline.some(part => part.type !== 'thinking') && (
{(split.timeline.length > 0 || !!msg.runId) && (
<div className="process-collapse">
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
trigger={<>
<span className="process-collapse-chev">▸</span>
<span className={live ? 'mk-sweep' : ''}>{headline}</span>
</>}>
<div className="body">{timeline}</div>
<div className="body">
{replayed === null && !live && !processError && <div className="process-load-note">正在读取完整记录…</div>}
{processError && <div className="process-load-note">完整记录暂不可用,以下为已保存的摘要</div>}
{processParts.length > 0 ? timeline : replayed && <div className="process-load-note">这轮没有可回看的过程记录</div>}
</div>
</Collapsible>
</div>
)}
@@ -486,6 +524,7 @@ function ConversationSession() {
const cards = useStore(s => s.cards)
const currentProjectId = useStore(s => s.currentProjectId)
const cardActionStates = useStore(s => s.cardActionStates)
const workPaneOwnsCards = useStore(s => !s.ui.rightCollapsed && s.ui.rightTab === 'work')
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
@@ -636,13 +675,16 @@ function ConversationSession() {
</div>
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
{anchored.map(c => (
<InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
))}
{anchored.map(c => workPaneOwnsCards
? <button type="button" key={c.id} className="conversation-action-anchor" onClick={() => store.openInbox(c.id)}>
<span>{c.title}</span><span>在工作栏处理 ↗</span>
</button>
: <InlineConfirmCard key={c.id} card={c} callbacks={{
onAction: (card, actionId) => store.cardAction(card, actionId),
actionState: card => cardActionStates?.[card.id],
onLocate: card => store.openInbox(card.id),
}} />
)}
</div>
)
}
+5 -3
View File
@@ -11,8 +11,10 @@ export function ConversationWork() {
const messages = useStore(s => s.messages)
const actionStates = useStore(s => s.cardActionStates)
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
const [summaryOpen, setSummaryOpen] = useState(rightCollapsed)
useEffect(() => setSummaryOpen(rightCollapsed), [rightCollapsed])
const rightTab = useStore(s => s.ui.rightTab)
const detailHere = rightCollapsed || rightTab !== 'work'
const [summaryOpen, setSummaryOpen] = useState(detailHere)
useEffect(() => setSummaryOpen(detailHere), [detailHere])
const { current, required, focused, history } = useMemo(() => {
const current = cards.filter(card => card.status !== 'dismissed'
&& card.conversationId === conversationId && card.projectId === projectId)
@@ -33,7 +35,7 @@ export function ConversationWork() {
?? remaining.find(card => card.status === 'done') ?? remaining[0]
return { current, required, focused, history: remaining.filter(card => card !== focused) }
}, [cards, messages, conversationId, projectId])
if (current.length === 0) return null
if (current.length === 0 || !detailHere) return null
const pending = pendingCount(current)
const active = current.filter(card => card.status === 'active').length
const results = current.filter(card => card.status === 'done' || card.status === 'failed').length
+12 -3
View File
@@ -1,6 +1,6 @@
// 执行记录按工作动作分组;展开状态由调用方控制。
import type { ReactNode } from 'react'
import { ToolBlock } from '@ui'
import { Collapsible, ToolBlock } from '@ui'
import type { Part } from '../contracts'
import { formatDuration, humanSize } from '../messageparts'
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
@@ -15,8 +15,12 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
return (
<div className="tl">
{rows.map((row, i) => {
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
if (row.kind === 'thinking') return null
if (row.kind === 'thinking') {
return row.text && <div className="tl-thinking" key={`${idKey}:th:${i}`}>
<span className="tl-thinking-label">思考过程</span>
<div className="tl-thinking-body">{row.text}</div>
</div>
}
if (row.kind === 'text') {
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
}
@@ -38,6 +42,11 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
<span className="tl-tools-name">{it.headline}</span>
{it.status === 'error' && <span className="status-error">失败</span>}
{it.summary && <span className="tl-tools-sum">{it.summary}</span>}
{(it.arguments || it.output) && <Collapsible id={`tool-record:${idKey}:${it.id}:${j}`}
triggerClassName="tl-tool-detail" trigger={<span>查看记录 ▸</span>}>
{it.arguments && <pre className="tool-preview">{it.arguments}</pre>}
{it.output && <pre className="tool-preview">{it.output}</pre>}
</Collapsible>}
</li>
))}
</ul>
+141 -6
View File
@@ -5,6 +5,8 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { api } from '../api'
import { Chip, Collapsible, EmptyState, SectionLabel } from '@ui'
import { classifyRdmError } from '../rdmErrors'
import { store, useStore } from '../store'
import { exportNeedsDraft } from '../workspaces'
// ---- 类型(U3 ListShell/详情的精简前端契约) ----
@@ -45,22 +47,30 @@ function RdmErrorState({ raw }: { raw: string }) {
}
export function RdmAuditWorkspace() {
const identity = useStore(() => store.getIdentityEpoch())
const [caps, setCaps] = useState<Capabilities | null>(null)
const [capsIdentity, setCapsIdentity] = useState(-1)
const [pid, setPid] = useState('')
const [tab, setTab] = useState<TabId>('summary')
const [error, setError] = useState('')
useEffect(() => {
let cancelled = false
setCaps(null); setPid(''); setError('')
api.rdmCommand<Capabilities>('rdm.inspect.capabilities')
.then(c => { if (!cancelled) { setCaps(c); if (c.projects.length > 0) setPid(prev => prev || c.projects[0].projectId) } })
.catch(e => { if (!cancelled) setError(e.message) })
.then(c => {
if (cancelled || identity !== store.getIdentityEpoch()) return
setCaps(c); setCapsIdentity(identity)
if (c.projects.length > 0) setPid(c.projects[0].projectId)
})
.catch(e => { if (!cancelled && identity === store.getIdentityEpoch()) setError(e.message) })
return () => { cancelled = true }
}, [])
}, [identity])
const project = caps?.projects.find(p => p.projectId === pid)
const exportOn = useStore(s => s.rdmExportAvailable)
if (error) return <RdmErrorState raw={error} />
if (!caps) return <div className="empty-note">加载中…</div>
if (!caps || capsIdentity !== identity) return <div className="empty-note">加载中…</div>
if (caps.projects.length === 0) return <EmptyState icon="folder">无可见研发项目——项目创建者与域管理员可查看追溯审计</EmptyState>
return (
@@ -82,7 +92,7 @@ export function RdmAuditWorkspace() {
))}
</div>
<div className="rdm-audit-body">
{tab === 'summary' && <SummaryTab pid={pid} />}
{tab === 'summary' && <SummaryTab pid={pid} exportOn={exportOn} />}
{tab === 'materials' && <MaterialsTab pid={pid} />}
{tab === 'trace' && <TraceTab pid={pid} />}
{tab === 'timeline' && <TimelineTab pid={pid} />}
@@ -93,7 +103,7 @@ export function RdmAuditWorkspace() {
// ---- 概览 ----
function SummaryTab({ pid }: { pid: string }) {
function SummaryTab({ pid, exportOn }: { pid: string; exportOn: boolean }) {
const [data, setData] = useState<SummaryData | null>(null)
const [err, setErr] = useState('')
useEffect(() => {
@@ -133,6 +143,131 @@ function SummaryTab({ pid }: { pid: string }) {
{data.coverage && data.coverage.length > 0 && (
<CoverageList cov={data.coverage} />
)}
{exportOn && <ExportSection pid={pid} />}
</div>
)
}
// ---- 概览导出(三条命令都启用才挂上;不参与七命令就绪门) ----
interface ExportCreated {
exportId: string
fileName: string
watermark: number
sha256: string
draft?: boolean
}
interface ExportStatus {
expiresAt: string
sha256: string
watermark: number
}
interface ExportDownloaded {
fileName: string
mime: string
dataB64: string
sha256: string
}
function saveExportFile(fileName: string, mime: string, dataB64: string) {
const bin = atob(dataB64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
const blob = new Blob([bytes], { type: mime || 'application/octet-stream' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = fileName
a.click()
URL.revokeObjectURL(url)
}
function ExportSection({ pid }: { pid: string }) {
const identity = useStore(() => store.getIdentityEpoch())
const generation = useRef(0)
const [kind, setKind] = useState<'cgraph-docx' | 'cgraph-matrix'>('cgraph-docx')
const [busy, setBusy] = useState(false)
const [err, setErr] = useState('')
const [needDraft, setNeedDraft] = useState(false)
const [artifact, setArtifact] = useState<(ExportCreated & { expiresAt: string }) | null>(null)
useEffect(() => {
generation.current++
setArtifact(null)
setErr('')
setNeedDraft(false)
setBusy(false)
return () => { generation.current++ }
}, [pid, identity])
const generate = async (draft: boolean) => {
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
setNeedDraft(false)
try {
const created = await api.rdmCommand<ExportCreated>('rdm.inspect.export.create', { projectId: pid, kind, draft })
if (!current()) return
const st = await api.rdmCommand<ExportStatus>('rdm.inspect.export.status', { projectId: pid, exportId: created.exportId })
if (!current()) return
setArtifact({ ...created, expiresAt: st.expiresAt })
} catch (e) {
if (!current()) return
const msg = e instanceof Error ? e.message : String(e)
if (!draft && exportNeedsDraft(msg)) setNeedDraft(true)
else setErr(msg)
} finally {
if (current()) setBusy(false)
}
}
const download = async () => {
if (!artifact) return
const request = ++generation.current
const current = () => request === generation.current && identity === store.getIdentityEpoch()
setBusy(true)
setErr('')
try {
const dl = await api.rdmCommand<ExportDownloaded>('rdm.inspect.export.download', { projectId: pid, exportId: artifact.exportId })
if (!current()) return
saveExportFile(dl.fileName, dl.mime, dl.dataB64)
} catch (e) {
if (current()) setErr(e instanceof Error ? e.message : String(e))
} finally {
if (current()) setBusy(false)
}
}
return (
<div className="rdm-audit-export">
<SectionLabel>导出</SectionLabel>
<div className="rdm-audit-export-row">
<label className="rdm-audit-export-kind">
类型
<select className="ui-input" aria-label="导出类型" value={kind} onChange={e => setKind(e.target.value as 'cgraph-docx' | 'cgraph-matrix')}>
<option value="cgraph-docx">合规追溯报告</option>
<option value="cgraph-matrix">追溯矩阵</option>
</select>
</label>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(false) }}>生成</button>
</div>
{needDraft && (
<div className="rdm-audit-export-draft">
<p>正式报告不完整。可以生成带缺口标注的草稿。</p>
<button type="button" className="btn" disabled={busy} onClick={() => { void generate(true) }}>生成草稿</button>
</div>
)}
{err && <div className="rdm-audit-export-err">{err}</div>}
{artifact && (
<div className="rdm-audit-export-result">
<div>水位 {artifact.watermark}</div>
<div>sha256 {artifact.sha256}</div>
<div>有效期 {artifact.expiresAt ? new Date(artifact.expiresAt).toLocaleString('zh-CN') : '—'}</div>
{artifact.draft && <div>草稿</div>}
<button type="button" className="btn v-ghost" disabled={busy} onClick={() => { void download() }}>下载</button>
</div>
)}
</div>
)
}
+2 -2
View File
@@ -36,7 +36,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<Icon name="message" size={15} /><span>对话</span>
</button>
</>}
<button className="shell-nav-button" aria-label="打开工作"
{(compact || ui.rightCollapsed) && <><button className="shell-nav-button" aria-label="打开工作"
aria-pressed={compact ? activePane === 'work' : !ui.rightCollapsed && ui.rightTab === 'work'}
title={pending > 0 ? `${pending} 项待处理` : '工作'} onClick={onWork}>
<Icon name="inbox" size={15} /><span>工作</span>
@@ -45,7 +45,7 @@ export function TopBar({ compact = false, activePane = 'conversation', showRetur
<button className="shell-nav-button" aria-label="打开文件"
aria-pressed={compact ? activePane === 'files' : !ui.rightCollapsed && ui.rightTab === 'files'} onClick={onFiles}>
<Icon name="folder" size={15} /><span>文件</span>
</button>
</button></>}
</nav>
</header>
)
+49
View File
@@ -278,6 +278,18 @@ interface DutyCatalogDoc {
assigned: boolean; author?: string; reviewers?: string[]; approver?: string; activeRound?: number
}
interface DocsTemplateRow {
templateId: string; version: string; title: string; docTypes: string[]; stage?: string
}
/** rdm.docs.instantiate 参数:docType 取首个适用类型;空标题交由后端取模板标题。 */
export function docsInstantiateParams(tpl: DocsTemplateRow, title: string): Record<string, unknown> {
return {
docType: tpl.docTypes[0], templateId: tpl.templateId, version: tpl.version,
...(title.trim() ? { title: title.trim() } : {}),
}
}
interface DutyGetOut {
docId: string; title: string; assigned: boolean
roles: { author: string; reviewers: string[]; approver: string }
@@ -320,6 +332,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
const [err, setErr] = useState('')
const [notice, setNotice] = useState('')
const [busy, setBusy] = useState(false)
const [tpls, setTpls] = useState<DocsTemplateRow[] | null>(null)
const [tplSel, setTplSel] = useState('')
const [newTitle, setNewTitle] = useState('')
const cmd = <T,>(id: string, params: Record<string, unknown>) => api.rdmProjectCommand<T>(project, id, params)
const memberPage = useMemberPickerPage(members)
@@ -388,6 +403,9 @@ export function RdmDutiesSection({ project }: { project: Project }) {
cmd<{ members: { owner: string; role: string }[] }>('rdm.project.member.list', { projectId })
.then(out => { if (currentProject(epoch)) setMembers(out.members ?? []) })
.catch(() => { if (currentProject(epoch)) setMembers([]) })
api.rdmCommand<DocsTemplateRow[]>('rdm.docs.templates')
.then(rows => { if (currentProject(epoch)) setTpls(rows ?? []) })
.catch(() => { if (currentProject(epoch)) setTpls([]) })
return () => {
requests.current.epoch++; requests.current.selection++; requests.current.status++
requests.current.catalog++; requests.current.action++; requests.current.selected = ''
@@ -431,8 +449,39 @@ export function RdmDutiesSection({ project }: { project: Project }) {
cmd('rdm.review.assign.doc.batch', { projectId, docs: [...batchSel].map(d => ({
docId: d, author: batchAuthor[0], reviewers: batchReviewers, approver: batchApprover[0] })) }))
/** U4/UI:按已发布 DHF 模板整份生成文档(章节骨架自动播种),随后选中新文档。 */
const instantiate = () => {
const tpl = (tpls ?? []).find(r => `${r.templateId}@${r.version}` === tplSel)
if (!tpl) return
const title = newTitle.trim()
return run(`已按模板生成「${title || tpl.title}」`, async () => {
const res = await cmd<{ docId: string }>('rdm.docs.instantiate', docsInstantiateParams(tpl, title))
await refreshCatalog()
await selectDoc(res.docId, true)
})
}
const tpl = (tpls ?? []).find(r => `${r.templateId}@${r.version}` === tplSel)
return (
<div>
<h4 className="settings-group-title">按模板生成文档</h4>
<div className="ad-create">
<div className="ad-row">
<select className="lg-input" aria-label="文档模板" value={tplSel}
onChange={e => { setTplSel(e.target.value); setNewTitle('') }}>
<option value="">{tpls === null ? '模板加载中…' : '选择模板…'}</option>
{(tpls ?? []).map(r => (
<option key={`${r.templateId}@${r.version}`} value={`${r.templateId}@${r.version}`}>
{r.title}({r.templateId}@{r.version})
</option>
))}
</select>
<input className="lg-input" aria-label="文档标题" placeholder={`文档标题(默认:${tpl?.title ?? '模板标题'})`}
value={newTitle} onChange={e => setNewTitle(e.target.value)} />
<button className="lg-submit ad-create-btn" disabled={busy || !tpl} onClick={instantiate}>生成文档</button>
</div>
<p className="ad-hint">按已发布 DHF 模板整份生成:章节骨架自动播种;生成后在下方清单选中文档分配三权并走整文审签。</p>
</div>
<h4 className="settings-group-title">文档清单(职责分配对象为整份文档;搜索/浏览同一目录)</h4>
<table className="ad-table">
<thead><tr><th></th><th>文档</th><th>类型 / 阶段</th><th>当前职责</th><th>状态</th><th></th></tr></thead>
+61 -4
View File
@@ -3,8 +3,9 @@
// 部件,事件到达序即落库序,故合成消息与落库部件同构,终态对账不重排。
// 文本段是否为过程(process)由「本段之后、下一个思考/文本段之前是否出现工具」判定。
// 参数增量(编写中)一到即算工具,解说不用等工具真正开始执行才离开回复区。
import type { Message, Part } from './contracts'
import type { Message, Part, StreamEvent } from './contracts'
import type { LiveState } from './store'
import { splitTimeline, type ReplayPart } from './messageparts'
export type ToolLiveState = 'composing' | 'running' | 'done'
@@ -12,6 +13,7 @@ export type ToolLiveState = 'composing' | 'running' | 'done'
export type LiveItem =
| { kind: 'thinking'; text: string; startedAt: number; endedAt: number }
| { kind: 'text'; index: number; text: string }
| { kind: 'agent'; text: string }
| {
kind: 'tool'
callId?: string
@@ -118,13 +120,13 @@ export function applyToolCall(items: LiveItem[], name: string, callId?: string,
}
// applyToolOutput 把执行中的输出追加到对应工具,只保留尾部,避免长日志撑住内存。
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string): LiveItem[] {
export function applyToolOutput(items: LiveItem[], callId: string | undefined, text: string, maxChars = OUTPUT_TAIL): LiveItem[] {
for (let i = items.length - 1; i >= 0; i--) {
const it = items[i]
if (!isTool(it) || it.state === 'done') continue
if (callId && it.callId && it.callId !== callId) continue
let output = it.output + text
if (output.length > OUTPUT_TAIL) output = output.slice(output.length - OUTPUT_TAIL)
if (output.length > maxChars) output = output.slice(output.length - maxChars)
const out = items.slice()
out[i] = { ...it, output }
return out
@@ -193,7 +195,9 @@ export function liveToMessage(live: LiveState): Message {
parts.push({ type: 'check', process: true, check: { passed: it.passed, reason: it.reason || undefined } })
continue
}
if (it.kind === 'thinking') {
if (it.kind === 'agent') {
parts.push({ type: 'text', text: it.text, process: true, replaySource: 'agent' } as ReplayPart)
} else if (it.kind === 'thinking') {
const durationMs = it.endedAt - it.startedAt
if (it.text) parts.push({
type: 'thinking', text: it.text, process: true,
@@ -224,6 +228,59 @@ export function liveToMessage(live: LiveState): Message {
return { id: `live:${live.runId}`, conversationId: '', role: 'assistant', runId: live.runId, parts, createdAt: '' }
}
/** Rebuild one run's process from its durable ordered events, without the live preview's output-tail limit. */
export function runEventsToTimeline(events: StreamEvent[], parentRunId = events[0]?.runId): ReplayPart[] {
let items: LiveItem[] = []
let finalizing = false
for (const event of [...events].sort((a, b) => a.seq - b.seq)) {
const p = event.payload as Record<string, unknown> | undefined
const now = Date.parse(event.ts) || 0
if (event.runId !== parentRunId && event.type !== 'agent.progress' && event.type !== 'agent.result') continue
switch (event.type) {
case 'agent.spawn':
if (typeof p?.name === 'string') items.push({ kind: 'agent', text: `协作代理「${p.name}」已开始` })
break
case 'agent.progress':
if (typeof p?.note === 'string' && p.note.trim()) items.push({ kind: 'agent', text: `协作进展:${p.note}` })
break
case 'agent.result':
items.push({ kind: 'agent', text: `协作结束:${typeof p?.summary === 'string' && p.summary.trim() ? p.summary : String(p?.status ?? '已结束')}` })
break
case 'thinking.delta':
if (typeof p?.text === 'string') items = applyThinkingDelta(items, p.text, now)
break
case 'message.delta':
if (!finalizing && typeof p?.text === 'string') items = applyTextDelta(items, typeof p.index === 'number' ? p.index : 0, p.text)
break
case 'tool.call.delta':
if (typeof p?.delta === 'string') items = applyToolCallDelta(items, typeof p.index === 'number' ? p.index : 0,
p.delta, typeof p.callId === 'string' ? p.callId : undefined, typeof p.name === 'string' ? p.name : undefined, now)
break
case 'tool.call':
items = applyToolCall(items, typeof p?.name === 'string' ? p.name : '工具',
typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.arguments === 'string' ? p.arguments : undefined, now)
break
case 'tool.output':
if (typeof p?.text === 'string') items = applyToolOutput(items,
typeof p.callId === 'string' ? p.callId : undefined, p.text, Number.POSITIVE_INFINITY)
break
case 'tool.result':
items = applyToolResult(items, typeof p?.callId === 'string' ? p.callId : undefined,
typeof p?.status === 'string' ? p.status : 'ok', typeof p?.summary === 'string' ? p.summary : undefined, now)
break
case 'run.phase':
if (p?.phase === 'finalizing') { finalizing = true; items = applyFinalizing(items) }
break
case 'run.check':
items = applyCheck(items, !!p?.passed, typeof p?.reason === 'string' ? p.reason : '')
break
}
}
const live: LiveState = { runId: events[0]?.runId ?? '', phase: 'done', items, agentNotes: {}, startedAt: 0 }
return splitTimeline(liveToMessage(live).parts).timeline
}
// livePhaseLabel 用最后一个实时条目给出中文阶段,而不是后端的英文 phase。
export function livePhaseLabel(live: LiveState, now = Date.now()): string {
if (live.phase === 'finalizing') return '整理回复中'
+75 -10
View File
@@ -1,5 +1,4 @@
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
// 消息切分:每轮过程按落库顺序留在可回看的时间线,终轮回复直接可读。
import type { Part } from './contracts'
export interface TimelineSplit {
@@ -7,20 +6,86 @@ export interface TimelineSplit {
final: Part[]
}
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
export function splitTimeline(parts: Part[], _promoteLastText = false): TimelineSplit {
/** Client-only provenance. It is never written to the Core message contract. */
export interface ReplayPart extends Part { replaySource?: 'agent' }
/** 最后一个 process 部件之前的记录属于该轮过程;错误与文件入口始终可见。
* 终态没有回复时,只把最后一段过程解说提升为可见回复。 */
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
let last = -1
for (let i = 0; i < parts.length; i++) if (parts[i].process) last = i
if (last < 0) return { timeline: [], final: parts }
// text 是公开解说;thinking、工具参数与结果、终轮检查不提升为正文。
const visible = (part: Part) => part.type === 'text' || part.type === 'error' || part.type === 'artifact_ref'
const before = parts.slice(0, last + 1)
const timeline = before.filter(part => !visible(part))
const final = [...before.filter(visible), ...parts.slice(last + 1)]
const timeline: Part[] = []
const final: Part[] = []
for (let i = 0; i < parts.length; i++) {
const part = parts[i]
if (i > last || part.type === 'error' || part.type === 'artifact_ref') final.push(part)
else timeline.push(part)
}
if (promoteLastText && !final.some(part => part.type === 'text' || part.type === 'error')) {
let idx = -1
for (let i = timeline.length - 1; i >= 0; i--) {
if (timeline[i].type === 'text') { idx = i; break }
}
if (idx >= 0) final.unshift(...timeline.splice(idx, 1))
}
return { timeline, final }
}
const normalized = (text?: string) => (text ?? '').replace(/\s+/g, ' ').trim()
function sameProcessPart(saved: Part, replayed: Part): boolean {
if (saved.type !== replayed.type) return false
switch (saved.type) {
case 'tool_call': return !!saved.toolCall?.id && saved.toolCall.id === replayed.toolCall?.id
case 'tool_result': return !!saved.toolResult?.callId && saved.toolResult.callId === replayed.toolResult?.callId
case 'check': return saved.check?.passed === replayed.check?.passed && saved.check?.reason === replayed.check?.reason
case 'thinking':
case 'text': return normalized(saved.text) === normalized(replayed.text)
default: return false
}
}
/** Keep the settled message as the content authority; add only event-only records
* and full tool output. Event deltas can be missing, while terminal parts can
* contain retry notes or parsed inline thinking that the event stream cannot reconstruct. */
export function mergeProcessTimeline(saved: Part[], replayed: ReplayPart[], final: Part[]): Part[] {
if (replayed.length === 0) return saved
const finalTexts = final.filter(part => part.type === 'text').map(part => normalized(part.text)).filter(Boolean)
const merged: Part[] = []
let cursor = 0
let pending: Part[] = []
for (const part of replayed) {
const text = normalized(part.text)
const agentNote = part.replaySource === 'agent'
if (part.type === 'text' && !agentNote && ((text && finalTexts.some(value => value.includes(text) || text.includes(value)))
|| /<\/?think\b/i.test(part.text ?? ''))) continue
let match = -1
for (let i = cursor; i < saved.length; i++) {
if (sameProcessPart(saved[i], part)) { match = i; break }
}
if (match < 0) {
// A missing delta batch can leave only a prefix in events. The complete
// terminal part is authoritative; never render that prefix beside it.
if (!agentNote && (part.type === 'text' || part.type === 'thinking') && text && saved.some(item => {
if (item.type !== part.type) return false
const value = normalized(item.text)
return value && (value.includes(text) || text.includes(value))
})) continue
pending.push(part)
continue
}
merged.push(...saved.slice(cursor, match), ...pending)
pending = []
const output = (part as Part & { toolOutput?: string }).toolOutput
merged.push(output && saved[match].type === 'tool_call'
? { ...saved[match], toolOutput: output } as Part : saved[match])
cursor = match + 1
}
merged.push(...saved.slice(cursor), ...pending)
return merged
}
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
export function dedupeDeliverables(parts: Part[]): { artifact: NonNullable<Part['artifact']>; updates: number }[] {
const order: string[] = []
+14 -4
View File
@@ -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 { rdmAuditReady } from './workspaces'
import { rdmAuditReady, rdmExportReady } from './workspaces'
import { rdmRolesReady, rdmMembersReady, rdmDocReviewReady, rdmPermMatrixReady } from './rdmManagement'
type FontSizeChoice = 13 | 14 | 16 | 18
@@ -121,6 +121,8 @@ interface AppState {
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
/** 审计入口能力(增量 2):GET /api/commands 含全部 rdm.inspect.* 且启用;探测失败/未登录为 false,入口隐藏。 */
rdmAuditAvailable: boolean
/** 概览导出区:三条 rdm.inspect.export.* 都启用才为 true。不参与七命令就绪门。 */
rdmExportAvailable: boolean
/** U6 RDM 管理页能力:命令面齐备才显示对应分区/页签(普通基座零暴露,fail-closed)。 */
rdmRolesAvailable: boolean
rdmMembersAvailable: boolean
@@ -194,6 +196,7 @@ let state: AppState = {
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
rdmAuditAvailable: false,
rdmExportAvailable: false,
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
adminSections: [],
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
@@ -404,6 +407,7 @@ export function useStore<T>(selector: (s: AppState) => T): T {
export const store = {
getState: () => state,
getIdentityEpoch: () => identityEpoch,
resetIdentity() {
identityEpoch++; projectsRequest++; tasksRequest++; meRequest++
@@ -424,7 +428,7 @@ export const store = {
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv',
runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], adminSections: [],
rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
@@ -1167,6 +1171,7 @@ export const store = {
if (epoch !== identityEpoch) return
set({
rdmAuditAvailable: rdmAuditReady(cmds),
rdmExportAvailable: rdmExportReady(cmds),
rdmRolesAvailable: rdmRolesReady(cmds),
rdmMembersAvailable: rdmMembersReady(cmds),
rdmDocReviewAvailable: rdmDocReviewReady(cmds),
@@ -1174,7 +1179,7 @@ export const store = {
})
} catch {
if (epoch !== identityEpoch) return
set({ rdmAuditAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
set({ rdmAuditAvailable: false, rdmExportAvailable: false, rdmRolesAvailable: false, rdmMembersAvailable: false,
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false })
}
try {
@@ -1322,7 +1327,12 @@ export const store = {
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'work' })
if (cardId) {
const scope = state.cards.some(card => card.id === cardId) ? 'conv'
: state.projectCards.some(card => card.id === cardId) ? 'project' : 'conv'
set({ inboxScope: scope })
}
setUI({ rightCollapsed: false, rightTab: 'work', ...(cardId ? { inboxFilter: 'all' as const } : {}) })
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
+13
View File
@@ -194,6 +194,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
.tl-tool-detail { margin-top: 4px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
.tl-thinking { margin: 8px 0; padding: 8px 12px; border-left: 2px solid var(--border-strong); color: var(--text-dim); }
.tl-thinking-label { display: block; margin-bottom: 4px; font-size: var(--fs-ui-sm); font-weight: 600; }
.tl-thinking-body { white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--fs-ui-sm); line-height: 1.6; }
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
@@ -226,6 +230,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.process-collapse .process-collapse-meta { color: var(--text-dim); }
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
.process-collapse .body { padding: 4px 12px 10px; }
.process-load-note { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-sm); }
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
.live-bar { display: flex; align-items: center; gap: 8px; color: var(--text-faint); font-size: var(--fs-ui-sm); margin-bottom: 6px; }
@@ -627,6 +632,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.rdm-audit-event-actor { color: var(--text-dim); min-width: 4em; }
.rdm-audit-event-detail { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
.rdm-audit-coverage { margin-top: 12px; }
.rdm-audit-export { margin-top: 16px; display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-export-row { display: flex; align-items: flex-end; gap: 8px; flex-wrap: wrap; }
.rdm-audit-export-kind { display: flex; flex-direction: column; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-kind .ui-input { min-width: 180px; height: 32px; font-size: var(--fs-ui-sm); }
.rdm-audit-export-draft { padding: 8px 10px; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui-sm); color: var(--text); }
.rdm-audit-export-draft p { margin: 0 0 8px; }
.rdm-audit-export-result { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-export-err { font-size: var(--fs-ui-sm); color: var(--err-text); }
.rdm-audit-coverage-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
.rdm-audit-err { display: flex; flex-direction: column; gap: 8px; }
.rdm-audit-err-toggle { font-size: var(--fs-ui-caption); color: var(--text-dim); }
+18
View File
@@ -30,6 +30,24 @@ export const RDM_AUDIT_COMMANDS = [
'rdm.inspect.history',
] as const
/** 概览页导出区单独探测。不并入上面的七命令就绪门。 */
export const RDM_EXPORT_COMMANDS = [
'rdm.inspect.export.create',
'rdm.inspect.export.status',
'rdm.inspect.export.download',
] as const
/** 三条导出命令都启用才显示概览导出区。缺一或清单不可用则隐藏。 */
export function rdmExportReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
return RDM_EXPORT_COMMANDS.every(need => cmds.some(c => c?.id === need && c.enabled === true))
}
/** 正式报告被完整性门拒绝时,界面先确认再带 draft 重试。 */
export function exportNeedsDraft(message: string): boolean {
return /不完整|草稿/.test(message)
}
/** GET /api/commands 清单是否足以支撑审计工作区:七命令齐备且 enabled。清单不可用(未登录/请求失败)视为未就绪。 */
export function rdmAuditReady(cmds: { id?: string; enabled?: boolean }[] | null | undefined): boolean {
if (!Array.isArray(cmds)) return false
+50 -8
View File
@@ -114,25 +114,30 @@ describe('conversation work reading', () => {
if (pinned) expect(html).not.toContain('data-card-id="work_active"')
})
it('keeps the newest completed result as a compact anchor with the auxiliary pane open', () => {
it('leaves completed results to the visible right work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = completedResults()
const html = renderWork()
expect(html).toContain('最新整理成果 · 查看摘要')
expect(html).not.toContain('最新成果的主要结论')
expect(html).not.toContain('先前整理成果')
expect(html).toBe('')
})
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
it('keeps the work card in one detail location when the right work pane is visible', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card()]
const html = render()
expect(html).toContain('整理项目材料')
expect(html).not.toContain('data-conversation-work')
expect(html).not.toContain('核对会议结论')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('核对会议结论')
})
it('keeps the conversation detail when the right pane is reading files', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.ui.rightTab = 'files'
snapshot.state!.cards = [card()]
expect(render()).toContain('核对会议结论')
})
it('keeps secondary message operations available through progressive disclosure', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
const html = render()
@@ -190,6 +195,29 @@ describe('conversation work reading', () => {
expect(html).toContain('确认新增')
})
it('shows only a location link for anchored confirmation when the work pane owns its actions', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.messages = [{ ...reply([{ type: 'text', text: '请整理资料' }]), role: 'user' }]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
const html = render()
expect(html).toContain('新增一份项目草稿')
expect(html).toContain('在工作栏处理')
expect(html).not.toContain('确认新增')
})
it('keeps a submitted card as a location link when its full body is in the work pane', () => {
snapshot.state!.ui.rightCollapsed = false
snapshot.state!.cards = [card({ id: 'result_1', kind: 'result', title: '材料核对结果', status: 'done',
body: [{ type: 'markdown', markdown: '材料核对的完整正文' }] })]
snapshot.state!.messages = [reply([{ type: 'card', card: { cardId: 'result_1', kind: 'result', data: [] } }])]
const html = render()
expect(html).toContain('材料核对结果')
expect(html).not.toContain('展开全文')
snapshot.state!.ui.rightCollapsed = true
expect(render()).toContain('展开全文')
})
it('keeps a required confirmation accessible when its anchor is an assistant message', () => {
snapshot.state!.messages = [reply([{ type: 'text', text: '准备新增项目草稿' }])]
snapshot.state!.cards = [card({ kind: 'confirm', status: 'action_required', anchorMessageId: 'msg_1',
@@ -197,12 +225,26 @@ describe('conversation work reading', () => {
expect(render()).toContain('确认新增')
})
it('does not reveal internal thinking when records are deliberately opened', () => {
it('keeps a thinking-only record available for deliberate review', () => {
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'thinking', process: true, text: 'private-internal-deliberation' }],
live: true, idKey: 'thinking-test', renderPart: () => null,
}))
expect(html).not.toContain('private-internal-deliberation')
expect(html).toContain('private-internal-deliberation')
snapshot.state!.messages = [reply([{ type: 'thinking', process: true, text: 'private-internal-deliberation' },
{ type: 'text', text: '已完成核对' }])]
expect(render()).toContain('执行记录')
})
it('lets readers reopen the full output of a grouped read action', () => {
setPersistedOpen('tool-record:read-history:call-read:0', true)
const html = renderToStaticMarkup(createElement(ProcessTimeline, {
parts: [{ type: 'tool_call', process: true,
toolCall: { id: 'call-read', name: 'fs_read_file', arguments: '{"path":"材料.txt"}' },
toolOutput: 'full-read-output' } as Message['parts'][number]],
live: false, idKey: 'read-history', renderPart: () => null,
}))
expect(html).toContain('full-read-output')
})
it('blocks repeated HITL choices when the shared action result is unknown', () => {
+19 -3
View File
@@ -1,6 +1,6 @@
// 设计系统守护测试(父仓 DESIGN.md §11 禁止清单 · 宿主侧,2026-09-30)。
// 棘轮基线:现存豁免登记在下方;新增同类违规直接失败。收紧 = 修掉违规后删基线条目。
// 范围:src/styles.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
// 范围:src/**/*.css 与 src/**/*.tsx。@ui 组件库侧由 Agent-System-UI 的守护测试覆盖。
import { describe, it, expect } from 'vitest'
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, relative, dirname } from 'node:path'
@@ -36,8 +36,14 @@ describe('DESIGN.md 宪法在位', () => {
// ---- 2. 动效唯一所有者 ----
describe('动效唯一所有者', () => {
it('styles.css 无 @keyframes(全部走 @ui/motion.css 的 mk-* 类)', () => {
expect(stripCssComments(read(join(SRC, 'styles.css')))).not.toContain('@keyframes')
it('宿主 CSS 无 @keyframes 或私有 transition(全部走 @ui/motion.css 的 mk-* 类)', () => {
for (const path of walk(SRC).filter(p => p.endsWith('.css'))) {
const css = stripCssComments(read(path))
expect(css, `${relative(SRC, path)} 不得定义 @keyframes`).not.toContain('@keyframes')
const transitions = css.split('\n').filter(line => /transition\s*:/.test(line) && !/transition\s*:\s*none\b/.test(line))
if (path.endsWith('styles.css')) continue // 历史 transition 由现有棘轮逐步收紧;新分层 CSS 不再扩散。
expect(transitions, `${relative(SRC, path)} 动效必须消费 mk-*`).toEqual([])
}
})
})
@@ -53,6 +59,16 @@ describe('裸色值棘轮', () => {
const extra = found.filter(h => !allow.has(h))
expect(extra, `新增裸色值 ${[...new Set(extra)].join(', ')}——改用 tokens 令牌;确需豁免更新 HEX_BASELINE_STYLES 并写明理由`).toEqual([])
})
it('分层组件 CSS 只消费令牌,不派生私有颜色', () => {
const componentCss = walk(SRC).filter(p => p.endsWith('.css') && !p.endsWith('styles.css'))
const extra: string[] = []
for (const path of componentCss) {
const css = stripCssComments(read(path))
for (const hex of collectHex(css)) extra.push(`${relative(SRC, path)}: ${hex}`)
if (/color-mix\s*\(/.test(css)) extra.push(`${relative(SRC, path)}: color-mix`)
}
expect(extra, '分层组件 CSS 的颜色须由 @ui/tokens.css 提供').toEqual([])
})
it('组件 TSX 无裸色值', () => {
const extra: string[] = []
for (const p of walk(SRC).filter(p => /\.(tsx|ts)$/.test(p) && !p.endsWith('.d.ts'))) {
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { docsInstantiateParams } from '../src/components/adminRdmSections'
const tpl = {
templateId: 'dhf.2750fbaf1f67d10c', version: '1.0.0', title: '项目建议书',
docTypes: ['rd_proposal'], stage: '0. 商务阶段',
}
describe('docsInstantiateParams', () => {
it('carries docType/templateId/version and trims the title', () => {
expect(docsInstantiateParams(tpl, ' U5 试点项目建议书 ')).toEqual({
docType: 'rd_proposal', templateId: 'dhf.2750fbaf1f67d10c',
version: '1.0.0', title: 'U5 试点项目建议书',
})
})
it('omits blank titles so the backend falls back to the template title', () => {
const p = docsInstantiateParams(tpl, ' ')
expect(Object.hasOwn(p, 'title')).toBe(false)
expect(p).toEqual({ docType: 'rd_proposal', templateId: 'dhf.2750fbaf1f67d10c', version: '1.0.0' })
})
})
+36
View File
@@ -5,7 +5,9 @@ import {
applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated,
applyCheck, applyFinalizing,
hasSettledReply, liveToMessage, livePhaseLabel, showProcessInline, type LiveItem,
runEventsToTimeline,
} from '../src/live'
import type { StreamEvent } from '../src/contracts'
import type { LiveState } from '../src/store'
function live(over: Partial<LiveState> = {}): LiveState {
@@ -159,6 +161,40 @@ describe('liveToMessage', () => {
})
})
it('reopens the ordered complete process from persisted run events', () => {
const output = 'x'.repeat(5000)
const event = (seq: number, type: StreamEvent['type'], payload: unknown): StreamEvent =>
({ seq, type, payload, runId: 'r1', conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'thinking.delta', { index: 0, text: '先核对' }),
event(2, 'message.delta', { index: 0, text: '开始读取材料' }),
event(3, 'tool.call', { callId: 'call1', name: 'fs_read_file', arguments: '{"path":"a.txt"}' }),
event(4, 'tool.output', { callId: 'call1', text: output }),
event(5, 'tool.result', { callId: 'call1', status: 'ok', summary: '读取成功' }),
event(6, 'run.phase', { phase: 'finalizing' }),
event(7, 'message.delta', { index: 1, text: '最终回复' }),
])
expect(parts.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call', 'tool_result'])
expect((parts[2] as typeof parts[number] & { toolOutput: string }).toolOutput).toBe(output)
expect(parts.some(part => part.text === '最终回复')).toBe(false)
})
it('retains collaboration notes from a child after the parent has replied', () => {
const event = (seq: number, type: StreamEvent['type'], payload: unknown, runId = 'parent'): StreamEvent =>
({ seq, type, payload, runId, conversationId: 'c1', ts: '2026-10-14T10:00:00Z', v: 1 })
const parts = runEventsToTimeline([
event(1, 'agent.spawn', { agentRun: 'child', name: '资料核对', task: '读附录' }),
event(2, 'run.phase', { phase: 'finalizing' }),
event(3, 'message.delta', { index: 0, text: '最终回复' }),
event(4, 'agent.progress', { agentRun: 'child', note: '正在核对附录' }, 'child'),
event(5, 'agent.result', { agentRun: 'child', status: 'completed', summary: '附录已核对' }, 'child'),
], 'parent')
expect(parts.map(part => part.text)).toEqual([
'协作代理「资料核对」已开始', '协作进展:正在核对附录', '协作结束:附录已核对',
])
expect(parts.every(part => part.replaySource === 'agent')).toBe(true)
})
describe('livePhaseLabel', () => {
it('names the current activity in Chinese', () => {
let items: LiveItem[] = applyThinkingDelta([], '想', 0)
+54 -19
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
import { splitTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { splitTimeline, mergeProcessTimeline, humanSize, artifactIcon, artifactIdsIn, markMissingArtifacts, workedLabel, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../src/messageparts'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolOutput, liveToMessage, showProcessInline } from '../src/live'
import type { Part } from '../src/contracts'
@@ -8,7 +8,7 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
}
describe('splitTimeline', () => {
it('keeps public Markdown before, between and after tools in reading order', () => {
it('keeps intermediate narration with its tools in reading order and the reply outside', () => {
const parts: Part[] = [
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
@@ -18,12 +18,11 @@ describe('splitTimeline', () => {
p({ type: 'text', text: '接下来确认日期冲突。' }),
]
const split = splitTimeline(parts)
expect(split.final.map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。',
'评审意见未能读取,完整结论尚未生成。',
'接下来确认日期冲突。',
expect(split.timeline.map(part => part.type)).toEqual(['text', 'tool_call', 'tool_result', 'text', 'tool_call'])
expect(split.timeline.filter(part => part.type === 'text').map(part => part.text)).toEqual([
'我会先核对**两份可读材料**。', '评审意见未能读取,完整结论尚未生成。',
])
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
expect(split.final.map(part => part.text)).toEqual(['接下来确认日期冲突。'])
})
it('keeps live scope limits visible while execution records are folded by default', () => {
@@ -36,9 +35,8 @@ describe('splitTimeline', () => {
expect(message.parts[1].process).toBe(true)
expect(showProcessInline(live, true)).toBe(false)
const split = splitTimeline(message.parts)
expect(split.final.map(part => part.text)).toEqual(['**范围限制:**目前仅整理两份材料,评审意见尚未读取。'])
expect(split.final.every(part => part.type === 'text')).toBe(true)
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call'])
expect(split.final).toEqual([])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'text', 'tool_call'])
})
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
@@ -52,8 +50,8 @@ describe('splitTimeline', () => {
]
for (const finished of [false, true]) {
const split = splitTimeline(parts, finished)
expect(split.final.map(part => part.text)).toEqual(['评审意见尚未读取。', '已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.map(part => part.text)).toEqual(['已整理两份材料,请补充清晰的评审意见。'])
expect(split.timeline.map(part => part.type)).toEqual(['text', 'thinking', 'tool_call', 'tool_result', 'check'])
expect(split.final.length + split.timeline.length).toBe(parts.length)
}
})
@@ -69,7 +67,7 @@ describe('splitTimeline', () => {
expect(split.timeline.map(x => x.type)).toEqual(['tool_call'])
expect(split.final.map(x => x.type)).toEqual(['error', 'artifact_ref', 'text'])
})
it('keeps only execution records folded when narration precedes the last process part', () => {
it('keeps narration beside execution records when it precedes the last process part', () => {
const parts: Part[] = [
p({ type: 'text', text: '先找资料', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
@@ -78,8 +76,8 @@ describe('splitTimeline', () => {
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
]
const split = splitTimeline(parts)
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
})
it('keeps a card sandwiched between process parts inside the timeline', () => {
@@ -94,7 +92,7 @@ describe('splitTimeline', () => {
expect(split.final.map(x => x.text)).toEqual(['最终'])
})
it('keeps all narrations visible even when a finished run has no final text', () => {
it('promotes only the last narration when a finished run has no final text', () => {
const parts: Part[] = [
p({ type: 'text', text: '先写一版', process: true }),
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'run_python' } }),
@@ -102,9 +100,9 @@ describe('splitTimeline', () => {
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'run_python' } }),
]
const split = splitTimeline(parts, true)
expect(split.final.map(x => x.text)).toEqual(['先写一版', '纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual(split.final)
expect(split.final.map(x => x.text)).toEqual(['纪要已经整理好'])
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
expect(splitTimeline(parts).final).toEqual([])
})
it('puts a message with no process parts entirely in the final section', () => {
@@ -115,6 +113,43 @@ describe('splitTimeline', () => {
})
})
it('enriches saved process without losing retry notes or parsed inline thinking', () => {
const saved: Part[] = [
p({ type: 'text', process: true, text: '模型在输出前失败,正在重试' }),
p({ type: 'thinking', process: true, text: '检查附件边界' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } }),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
]
const replayed: Part[] = [
p({ type: 'text', process: true, text: '<think>检查附件边界</think>' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' }, toolOutput: '完整输出' } as Part),
p({ type: 'tool_result', process: true, toolResult: { callId: 'call-1', status: 'ok' } }),
p({ type: 'text', process: true, text: '最终回复' }),
p({ type: 'text', process: true, text: '协作进展:附录已核对' }),
]
const merged = mergeProcessTimeline(saved, replayed, [p({ type: 'text', text: '最终回复' })])
expect(merged.map(part => part.text).filter(Boolean)).toEqual([
'模型在输出前失败,正在重试', '检查附件边界', '协作进展:附录已核对',
])
expect((merged[2] as Part & { toolOutput: string }).toolOutput).toBe('完整输出')
expect(merged.filter(part => part.type === 'tool_call')).toHaveLength(1)
})
it('does not repeat a partial event delta beside the complete saved narration', () => {
const saved = [p({ type: 'text', process: true, text: '开始读取材料' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
const replayed = [p({ type: 'text', process: true, text: '开始读取' }),
p({ type: 'tool_call', process: true, toolCall: { id: 'call-1', name: 'fs_read_file' } })]
expect(mergeProcessTimeline(saved, replayed, []).map(part => part.text).filter(Boolean)).toEqual(['开始读取材料'])
})
it('keeps a collaboration result even when the final reply contains its short status', () => {
const note = p({ type: 'text', process: true, text: '协作结束:已完成', replaySource: 'agent' } as Part)
const merged = mergeProcessTimeline([p({ type: 'text', process: true, text: '完成' })], [note],
[p({ type: 'text', text: '已完成' })])
expect(merged.map(part => part.text)).toEqual(['完成', '协作结束:已完成'])
})
describe('artifact ids in reply text', () => {
it('extracts unique ids in order', () => {
expect(artifactIdsIn('见 art_0123456789ab 和 `art_abcdefabcdef`,再提 art_0123456789ab'))
+14
View File
@@ -347,3 +347,17 @@ describe('identity-scoped state', () => {
expect(await pending).toBeNull()
})
})
describe('export capability identity', () => {
it('drops the old identity export capability before any new command refresh', async () => {
const previous = route
route = async (path, init) => path === '/api/commands'
? response(['rdm.inspect.export.create', 'rdm.inspect.export.status', 'rdm.inspect.export.download']
.map(id => ({ id, enabled: true }))) : previous(path, init)
await store.refreshPlugins()
expect(store.getState().rdmExportAvailable).toBe(true)
auth.beginSession()
expect(store.getState().rdmExportAvailable).toBe(false)
})
})
+26 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries, rdmAuditReady, RDM_AUDIT_COMMANDS } from '../src/workspaces'
import { buildWorkspaceEntries, rdmAuditReady, rdmExportReady, exportNeedsDraft, RDM_AUDIT_COMMANDS, RDM_EXPORT_COMMANDS } from '../src/workspaces'
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口;体系工具内置「代理」。
// 2026-10-12 增量 2(审计入口能力门控):「追溯审计」不再无条件内置——普通基座
@@ -72,4 +72,29 @@ describe('rdmAuditReady', () => {
expect(rdmAuditReady(null)).toBe(false)
expect(rdmAuditReady(undefined)).toBe(false)
})
it('导出命令不参与七命令就绪门', () => {
expect(rdmAuditReady(all)).toBe(true)
const withExportOff = [
...all,
{ id: 'rdm.inspect.export.create', enabled: false },
]
expect(rdmAuditReady(withExportOff)).toBe(true)
})
})
describe('rdmExportReady', () => {
const audit = RDM_AUDIT_COMMANDS.map(id => ({ id, enabled: true }))
const exp = RDM_EXPORT_COMMANDS.map(id => ({ id, enabled: true }))
it('三条导出命令启用才显示导出区', () => {
expect(rdmExportReady([...audit, ...exp])).toBe(true)
expect(rdmExportReady(audit)).toBe(false)
expect(rdmExportReady(exp.filter(c => c.id !== 'rdm.inspect.export.download'))).toBe(false)
})
it('正式报告不完整时才要求草稿确认', () => {
expect(exportNeedsDraft('正式报告不完整(合规缺口)——传 draft:true 生成草稿')).toBe(true)
expect(exportNeedsDraft('项目不存在')).toBe(false)
})
})