Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92e05392a7 | ||
|
|
9ea8249be8 | ||
|
|
dcd2699933 | ||
|
|
0159f009fa | ||
|
|
43d1933b8c | ||
|
|
91a832f93e | ||
|
|
37c668dbad | ||
|
|
e6eecb2bfa | ||
|
|
ac668d11d3 | ||
|
|
411a79d9e2 | ||
|
|
4965de269a |
@@ -0,0 +1,200 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
test.skip(!process.env.ADMIN_NOTES_REPAIR_BROWSER, 'Run against the task-owned UI preview')
|
||||
|
||||
// Fixed EasyList cosmetic rules that hid the real management controls on 8788.
|
||||
// https://raw.githubusercontent.com/easylist/easylist/master/easylist/easylist_general_hide.txt
|
||||
const cosmeticRules = '.ad-body,.ad-hint,.ad-notice,.ad-row,.ad-table { display:none!important }'
|
||||
const stamp = '2026-10-13T02:00:00Z'
|
||||
const commands = ['rdm.role.create', 'rdm.role.action', 'rdm.role.assign', 'rdm.role.disable', 'rdm.role.list',
|
||||
'rdm.inspect.capabilities', 'rdm.inspect.summary', 'rdm.inspect.tree', 'rdm.inspect.object',
|
||||
'rdm.inspect.relations', 'rdm.inspect.events', 'rdm.inspect.history']
|
||||
const roles = [
|
||||
{ roleId: 'engineer', name: '工程师', disabled: false, version: 1, actions: { 'project.create': [true, true], 'doc.write': [false, true] }, accounts: [] },
|
||||
{ roleId: 'reviewer', name: '审核员', disabled: false, version: 1, actions: { 'project.create': [false, false] }, accounts: [] },
|
||||
]
|
||||
const shell = (items: unknown[], nextCursor = '') => ({ items, hasMore: !!nextCursor, nextCursor, truncated: false, coverage: [] })
|
||||
|
||||
async function fixture(page: Page) {
|
||||
let detailFailed = false
|
||||
let childFailed = false
|
||||
let releaseSlow!: () => void
|
||||
let reportSlow!: () => void
|
||||
const slow = new Promise<void>(resolve => { releaseSlow = resolve })
|
||||
const slowStarted = new Promise<void>(resolve => { reportSlow = resolve })
|
||||
page.on('close', releaseSlow)
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'repair-admin', name: '修复验证', auth: true, authVia: 'session', isAdmin: true, mustChangePassword: false }
|
||||
else if (path === '/api/settings') body = {}
|
||||
else if (path === '/api/commands') body = commands.map(id => ({ id, enabled: true }))
|
||||
else if (path === '/api/admin-zone/sections') body = [{ id: 'rdm.audit', title: '项目追溯与审计', icon: 'search' }]
|
||||
else if (path === '/api/admin/users') body = [{ owner: 'repair-admin', name: '修复验证', isAdmin: true, mustChangePassword: false, hasPassword: true, createdAt: stamp }]
|
||||
else if (path.startsWith('/api/commands/')) {
|
||||
const command = decodeURIComponent(path.slice('/api/commands/'.length))
|
||||
const params = request.postDataJSON()?.params ?? {}
|
||||
let payload: unknown
|
||||
if (command === 'rdm.role.list') payload = { roles, count: 2 }
|
||||
else if (command === 'rdm.role.action') payload = params
|
||||
else if (command === 'rdm.inspect.capabilities') payload = { actor: 'repair-admin', crossProject: true, projects: [
|
||||
{ projectId: 'p1', name: '主项目', canAudit: true }, { projectId: 'p2', name: '隔离项目', canAudit: true },
|
||||
] }
|
||||
else if (command === 'rdm.inspect.summary') payload = { projectId: params.projectId, counts: {}, watermark: 10, coverage: [] }
|
||||
else if (command === 'rdm.inspect.tree') {
|
||||
if (params.projectId === 'p2') payload = params.kind === 'notes'
|
||||
? shell([{ kind: 'spine', id: 'other', title: '隔离骨架' }])
|
||||
: shell([{ kind: 'doc', id: 'other-doc', title: '隔离文档' }])
|
||||
else if (params.kind === 'docs') payload = shell(params.parent ? [{ kind: 'section', id: 'sec1', title: '章节一' }] : [{ kind: 'doc', id: 'doc1', title: '项目文档' }])
|
||||
else if (!params.parent) payload = shell([
|
||||
{ kind: 'spine', id: 'root', title: '阶段骨架' },
|
||||
{ kind: 'slice', id: 'slow', title: '慢速资料' }, { kind: 'slice', id: 'fast', title: '快速资料' },
|
||||
])
|
||||
else if (params.parent === 'root') payload = params.cursor ? shell([{ kind: 'slice', id: 'second', title: '第二页资料' }]) : shell([{ kind: 'spine', id: 'child', title: '需求骨架' }], 'root-page-2')
|
||||
else if (params.parent === 'child') {
|
||||
if (!childFailed) {
|
||||
childFailed = true
|
||||
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary child query failure' } } })
|
||||
}
|
||||
payload = shell([{ kind: 'slice', id: 'meeting', title: '会议资料' }])
|
||||
} else payload = shell([])
|
||||
} else if (command === 'rdm.inspect.object') {
|
||||
if (params.id === 'meeting' && !detailFailed) {
|
||||
detailFailed = true
|
||||
return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: 'temporary detail query failure' } } })
|
||||
}
|
||||
if (params.id === 'slow') { reportSlow(); await slow }
|
||||
const titles: Record<string, string> = { root: '阶段骨架详情', child: '需求骨架详情', meeting: '会议资料详情', slow: '慢速资料详情', fast: '快速资料详情', second: '第二页资料详情', sec1: '章节一详情', other: '隔离骨架详情' }
|
||||
payload = { kind: params.kind, id: params.id, projectId: params.projectId, title: titles[params.id], parentId: params.id === 'child' ? 'root' : '', childCount: 1, sliceCount: 1, relations: [], recentEvents: [], coverage: [] }
|
||||
} else return route.fulfill({ status: 400, json: { receipt: { state: 'rejected', reason: `unexpected command ${command}` } } })
|
||||
body = { receipt: { state: 'committed', commandId: command }, payload }
|
||||
}
|
||||
await route.fulfill({ status: 200, json: body })
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.tsb-user')).toContainText('修复验证')
|
||||
await page.locator('.tsb-user').click()
|
||||
await page.getByText('系统管理…', { exact: true }).click()
|
||||
return { slowStarted, releaseSlow }
|
||||
}
|
||||
|
||||
async function notes(page: Page) {
|
||||
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.getByRole('button', { name: '笔记树', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
|
||||
}
|
||||
|
||||
test('advertising filters leave management forms and role tables visible', async ({ page }, testInfo) => {
|
||||
await fixture(page)
|
||||
await page.addStyleTag({ content: cosmeticRules })
|
||||
await expect(page.getByPlaceholder('用户名(登录名)')).toBeVisible()
|
||||
await page.getByPlaceholder('用户名(登录名)').fill('new-user')
|
||||
await page.getByPlaceholder('初始密码(≥8 位)').fill('fixture-password')
|
||||
await expect(page.getByRole('button', { name: '创建账号', exact: true })).toBeEnabled()
|
||||
await expect(page.getByRole('cell', { name: 'repair-admin', exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('users-with-adblock.png') })
|
||||
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '创建角色', exact: true })).toBeVisible()
|
||||
await expect(page.getByRole('cell', { name: /工程师/ })).toBeVisible()
|
||||
await expect(page.getByLabel('默认允许')).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('roles-with-adblock.png') })
|
||||
})
|
||||
|
||||
test('selecting a role and action loads their actual permission checkboxes', async ({ page }) => {
|
||||
await fixture(page)
|
||||
await page.getByRole('button', { name: 'RDM 角色权限', exact: true }).click()
|
||||
const selects = page.locator('.az-main select')
|
||||
await selects.nth(0).selectOption('engineer')
|
||||
await expect(page.getByLabel('默认允许')).toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).toBeChecked()
|
||||
const saved = page.waitForRequest(r => r.method() === 'POST' && r.url().endsWith('/rdm.role.action'))
|
||||
const refreshed = page.waitForResponse(r => r.url().endsWith('/rdm.role.list'))
|
||||
await page.getByRole('button', { name: '保存动作', exact: true }).click()
|
||||
expect((await saved).postDataJSON()).toEqual({ params: {
|
||||
roleId: 'engineer', action: 'project.create', default: true, cap: true,
|
||||
} })
|
||||
await refreshed
|
||||
await expect(page.getByLabel('默认允许')).toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).toBeChecked()
|
||||
await selects.nth(1).selectOption('doc.write')
|
||||
await expect(page.getByLabel('默认允许')).not.toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).toBeChecked()
|
||||
await selects.nth(1).selectOption('project.create')
|
||||
await selects.nth(0).selectOption('reviewer')
|
||||
await expect(page.getByLabel('默认允许')).not.toBeChecked()
|
||||
await expect(page.getByLabel('上限允许')).not.toBeChecked()
|
||||
})
|
||||
|
||||
test('notes expand nested spines, retry a child query, and inspect mounted slices', async ({ page }, testInfo) => {
|
||||
await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('阶段骨架详情')
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
|
||||
await expect(page.getByRole('button', { name: '重试列表', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '重试列表', exact: true }).click()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' }).click()
|
||||
await expect(page.getByRole('button', { name: '重试详情', exact: true })).toBeVisible()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '重试详情', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('会议资料详情')
|
||||
await page.screenshot({ path: testInfo.outputPath('notes-detail.png') })
|
||||
})
|
||||
|
||||
test('each expanded spine can load its next page', async ({ page }) => {
|
||||
await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
|
||||
await page.getByRole('button', { name: '加载更多', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '第二页资料' })).toBeVisible()
|
||||
})
|
||||
|
||||
test('late detail responses cannot replace the newly selected object', async ({ page }) => {
|
||||
const control = await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
|
||||
await control.slowStarted
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '快速资料' }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
|
||||
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
|
||||
control.releaseSlow()
|
||||
await response
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('快速资料详情')
|
||||
})
|
||||
|
||||
test('project switching clears note descendants and in-flight object details', async ({ page }) => {
|
||||
const control = await fixture(page)
|
||||
await notes(page)
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toBeVisible()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' }).click()
|
||||
await page.getByRole('button', { name: '重试列表', exact: true }).click()
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toBeVisible()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '慢速资料' }).click()
|
||||
await control.slowStarted
|
||||
await page.locator('.rdm-audit-project').selectOption('p2')
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '隔离骨架' })).toBeVisible()
|
||||
const response = page.waitForResponse(r => r.url().endsWith('/rdm.inspect.object') && r.request().postDataJSON()?.params?.id === 'slow')
|
||||
control.releaseSlow()
|
||||
await response
|
||||
await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '阶段骨架' })).toHaveCount(0)
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '需求骨架' })).toHaveCount(0)
|
||||
await expect(page.locator('.rdm-audit-tree-node').filter({ hasText: '会议资料' })).toHaveCount(0)
|
||||
await expect(page.locator('.rdm-audit-detail')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('document expansion and section inspection remain available', async ({ page }) => {
|
||||
await fixture(page)
|
||||
await page.getByRole('button', { name: '项目追溯与审计', exact: true }).click()
|
||||
await page.getByRole('tab', { name: '资料与文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '项目文档' }).click()
|
||||
await page.locator('.rdm-audit-tree-node').filter({ hasText: '章节一' }).click()
|
||||
await expect(page.locator('.rdm-audit-detail')).toContainText('章节一详情')
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test, expect, type Page, type APIRequestContext, type Response } from '@playwright/test'
|
||||
import type { Artifact, Message, Part, Run, UploadInfo } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_LIVE_BROWSER, 'Requires the task-owned backend, SQLite data and MockRunner')
|
||||
|
||||
async function openRealConversation(page: Page, request: APIRequestContext) {
|
||||
const projectResponse = await request.post('/api/projects', { data: { name: '工作台验收 ' + Date.now() } })
|
||||
expect(projectResponse.ok()).toBe(true)
|
||||
const project = await projectResponse.json()
|
||||
const conversationResponse = await request.post(`/api/projects/${project.id}/conversations`, { data: { title: '真实接口验收' } })
|
||||
expect(conversationResponse.ok()).toBe(true)
|
||||
const conversation = await conversationResponse.json()
|
||||
await page.addInitScript(({ projectId, conversationId }) => {
|
||||
localStorage.setItem('as.conv', conversationId)
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', rightCollapsed: true, lastProjectId: projectId }))
|
||||
}, { projectId: project.id, conversationId: conversation.id })
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||
return { project, conversation }
|
||||
}
|
||||
|
||||
test('real upload/send/reload preserves three actual attachments', async ({ page, request }, testInfo) => {
|
||||
const { project, conversation } = await openRealConversation(page, request)
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const uploadResponses: Response[] = []
|
||||
const uploadPath = `/api/projects/${project.id}/uploads`
|
||||
const observeUpload = (response: Response) => {
|
||||
if (response.request().method() === 'POST' && new URL(response.url()).pathname === uploadPath) uploadResponses.push(response)
|
||||
}
|
||||
page.on('response', observeUpload)
|
||||
await page.locator('input[type=file]').first().setInputFiles([
|
||||
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议决定:先明确交付日期') },
|
||||
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求:整理材料并生成摘要') },
|
||||
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审意见:补充验收标准') },
|
||||
])
|
||||
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||
page.off('response', observeUpload)
|
||||
expect(uploadResponses).toHaveLength(3)
|
||||
const uploads: UploadInfo[] = await Promise.all(uploadResponses.map(async response => {
|
||||
expect(response.ok()).toBe(true)
|
||||
return response.json()
|
||||
}))
|
||||
expect(uploads.map(upload => upload.filename)).toEqual(['会议纪要.txt', '需求说明.txt', '评审意见.txt'])
|
||||
const attachmentIds = uploads.map(upload => upload.artifactId)
|
||||
for (const id of attachmentIds) expect(id).toEqual(expect.stringMatching(/\S+/))
|
||||
expect(new Set(attachmentIds).size).toBe(3)
|
||||
const artifactsResponse = await request.get(`/api/projects/${project.id}/artifacts`)
|
||||
expect(artifactsResponse.ok()).toBe(true)
|
||||
const artifacts: Artifact[] = await artifactsResponse.json()
|
||||
expect(artifacts).toHaveLength(3)
|
||||
expect(artifacts.map(artifact => artifact.id).sort()).toEqual([...attachmentIds].sort())
|
||||
const expectedParts: Part[] = uploads.map(upload => {
|
||||
const artifact = artifacts.find(artifact => artifact.id === upload.artifactId)!
|
||||
expect(artifact.name).toBe(upload.filename)
|
||||
return { type: 'artifact_ref', artifact: { artifactId: artifact.id, name: artifact.name, kind: artifact.kind } }
|
||||
})
|
||||
const messagePath = `/api/conversations/${conversation.id}/messages`
|
||||
const [sendRequest, sendResponse] = await Promise.all([
|
||||
page.waitForRequest(request => request.method() === 'POST' && new URL(request.url()).pathname === messagePath),
|
||||
page.waitForResponse(response => response.request().method() === 'POST' && new URL(response.url()).pathname === messagePath),
|
||||
page.getByRole('button', { name: '发送', exact: true }).click(),
|
||||
])
|
||||
const sent = sendRequest.postDataJSON()
|
||||
expect(sent.text).toBe('')
|
||||
expect(sent.attachmentIds).toEqual(attachmentIds)
|
||||
expect(sendResponse.ok()).toBe(true)
|
||||
const accepted: { message: Message; run: Run } = await sendResponse.json()
|
||||
expect(accepted.message.role).toBe('user')
|
||||
expect(accepted.message.conversationId).toBe(conversation.id)
|
||||
expect(accepted.message.parts).toEqual(expectedParts)
|
||||
expect(accepted.run.kind).toBe('conversation')
|
||||
await expect.poll(async () => {
|
||||
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||
expect(response.ok()).toBe(true)
|
||||
const current: { runs: Run[] } = await response.json()
|
||||
return current.runs.some(run => run.id === accepted.run.id && run.kind === 'conversation' && run.status === 'completed')
|
||||
}).toBe(true)
|
||||
async function persistedMessage() {
|
||||
const response = await request.get(`/api/conversations/${conversation.id}`)
|
||||
expect(response.ok()).toBe(true)
|
||||
const current: { messages: Message[] } = await response.json()
|
||||
const users = current.messages.filter(message => message.role === 'user')
|
||||
expect(users).toHaveLength(1)
|
||||
expect(users[0].id).toBe(accepted.message.id)
|
||||
expect(users[0].conversationId).toBe(conversation.id)
|
||||
expect(users[0].parts).toEqual(expectedParts)
|
||||
return users[0]
|
||||
}
|
||||
const beforeReload = await persistedMessage()
|
||||
await page.reload()
|
||||
await expect(page.locator('.chat-title')).toHaveText('真实接口验收')
|
||||
const afterReload = await persistedMessage()
|
||||
expect(afterReload).toEqual(beforeReload)
|
||||
const association = { projectId: project.id, conversationId: conversation.id, runId: accepted.run.id,
|
||||
uploads: uploads.map(({ artifactId, filename }) => ({ artifactId, filename })),
|
||||
sent: { text: sent.text, attachmentIds: sent.attachmentIds }, beforeReload, afterReload }
|
||||
await testInfo.attach('real-material-associations', { body: Buffer.from(JSON.stringify(association, null, 2)), contentType: 'application/json' })
|
||||
console.log('real-material-associations', JSON.stringify(association))
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await expect(page.locator('[data-artifact-row]')).toHaveCount(3)
|
||||
await page.locator('[data-artifact-row]').first().click()
|
||||
await expect(page.locator('.preview-pane-name')).toBeVisible()
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
|
||||
test('real background card and HITL decision survive query reconciliation', async ({ page, request }) => {
|
||||
const { conversation } = await openRealConversation(page, request)
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
const input = page.getByRole('textbox', { name: '工作目标或消息', exact: true })
|
||||
// Backend's deterministic runner exercises the real run/card/event lifecycle;
|
||||
// this is not a live-model quality or stage-findings test.
|
||||
await input.fill('#background')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(async () => {
|
||||
const out = await request.get(`/api/conversations/${conversation.id}/cards`).then(r => r.json())
|
||||
return out.cards?.some((card: { kind: string; status: string }) => card.kind === 'progress' && ['done', 'failed'].includes(card.status))
|
||||
}, { timeout: 15000 }).toBe(true)
|
||||
await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: '发送', exact: true })).toBeVisible()
|
||||
await input.fill('#hitl')
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect(page.locator('.hitl-card')).toBeVisible()
|
||||
await page.locator('.hitl-card').getByRole('button', { name: /Markdown 文档/ }).click()
|
||||
await expect(page.locator('.hitl-card')).toHaveCount(0)
|
||||
await expect.poll(async () => {
|
||||
const current = await request.get(`/api/conversations/${conversation.id}`).then(r => r.json())
|
||||
return current.activeHitl.length
|
||||
}).toBe(0)
|
||||
expect(errors).toEqual([])
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
import type { Artifact, Conversation, SurfaceMeta, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
const stamp = '2026-10-12T10:00:00Z'
|
||||
const conversation: Conversation = {
|
||||
id: 'navigation-conversation', projectId: 'navigation-project', title: '继续阅读材料',
|
||||
createdAt: stamp, updatedAt: stamp,
|
||||
}
|
||||
const artifact: Artifact = {
|
||||
id: 'navigation-document', projectId: conversation.projectId, name: '材料摘要.md', kind: 'markdown',
|
||||
size: 64, createdAt: stamp, updatedAt: stamp,
|
||||
}
|
||||
const snapshot: SurfaceSnapshot = {
|
||||
identity: { surfaceId: 'saved-navigation-page', owner: 'reader', pluginId: 'generative-ui', conversationId: conversation.id },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' }, revision: 1, activationEpoch: 1,
|
||||
spec: { title: '已保存的材料页面', nodes: [
|
||||
{ id: 'summary', type: 'markdown', markdown: '已保存的材料范围' },
|
||||
{ id: 'source', type: 'doc-ref', docId: artifact.id },
|
||||
] },
|
||||
}
|
||||
const metadata: SurfaceMeta = {
|
||||
surfaceId: snapshot.identity.surfaceId, title: snapshot.spec.title, pluginId: snapshot.identity.pluginId,
|
||||
revision: snapshot.revision, conversationId: conversation.id, updatedAt: stamp,
|
||||
}
|
||||
|
||||
// Delay the external list contract, while running the real store, App and panes.
|
||||
// The saved selection is restored by openConversation rather than test-only state injection.
|
||||
async function fixture(page: Page) {
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
let release!: () => void
|
||||
const hydration = new Promise<void>(resolve => { release = resolve })
|
||||
await page.addInitScript(({ conversationId }) => {
|
||||
localStorage.setItem('as.conv', conversationId)
|
||||
localStorage.setItem('as.ui', JSON.stringify({
|
||||
theme: 'light', rightTab: 'files', filesView: 'preview', rightCollapsed: true,
|
||||
}))
|
||||
}, { conversationId: conversation.id })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (request.method() !== 'GET') return route.fulfill({ status: 405, body: 'read-only navigation fixture' })
|
||||
if (path === '/api/events') return route.fulfill({ contentType: 'text/event-stream', body: ': connected\n\n' })
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'reader', name: '材料阅读者', auth: true, authVia: 'session', isAdmin: false }
|
||||
else if (path === '/api/projects') body = [{ id: conversation.projectId, name: '材料项目', createdAt: stamp, updatedAt: stamp }]
|
||||
else if (path === '/api/conversations') body = [conversation]
|
||||
else if (path === `/api/conversations/${conversation.id}`) body = { conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }
|
||||
else if (path === `/api/conversations/${conversation.id}/surfaces`) { await hydration; body = [metadata] }
|
||||
else if (path === `/api/surfaces/${snapshot.identity.surfaceId}`) body = snapshot
|
||||
else if (path === `/api/conversations/${conversation.id}/cards`) body = { cards: [] }
|
||||
else if (path === `/api/projects/${conversation.projectId}/artifacts`) body = [artifact]
|
||||
else if (path === `/api/artifacts/${artifact.id}/content`) {
|
||||
return route.fulfill({ contentType: 'text/markdown', body: '# 材料摘要\n\n原始材料的阅读范围' })
|
||||
} else if (path === '/api/settings') body = { user: {}, project: {}, effective: {}, schema: [] }
|
||||
else if (path === '/api/workspaces') body = [{ id: 'navigation-materials', title: '材料工作区',
|
||||
body: [{ type: 'doc-ref', docId: artifact.id }] }]
|
||||
else if (path.endsWith('/plan') || path.endsWith('/todos')) body = null
|
||||
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
const listRequest = page.waitForRequest(request => new URL(request.url()).pathname === `/api/conversations/${conversation.id}/surfaces`)
|
||||
await page.goto('/')
|
||||
await listRequest
|
||||
await expect(page.locator('.chat-title')).toHaveText(conversation.title)
|
||||
return { release, errors }
|
||||
}
|
||||
|
||||
for (const width of [736, 390]) {
|
||||
test(`keeps context and focus when saved Surface hydration arrives at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
const search = page.getByRole('textbox', { name: '搜索本项目会话', exact: true })
|
||||
await search.fill('材料')
|
||||
await expect(search).toBeFocused()
|
||||
state.release()
|
||||
// The real hidden preview proves both restored tab selection and snapshot hydration finished.
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'context')
|
||||
await expect(page.locator('.left-nav')).toBeVisible()
|
||||
await expect(page.locator('.main')).toBeHidden()
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
await expect(search).toHaveValue('材料')
|
||||
await expect(search).toBeFocused()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test(`preserves explicit work, Surface, document and return navigation at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
state.release()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'work')
|
||||
await expect(page.locator('.right-panel')).toBeVisible()
|
||||
await expect(page.locator('.left-nav')).toBeHidden()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||
await page.getByRole('button', { name: '返回文件列表', exact: true }).click()
|
||||
await page.locator(`[data-surface-row="${snapshot.identity.surfaceId}"]`).click()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
// Surface doc-ref uses the existing document bridge, which opens the same preview.
|
||||
await page.locator('.preview-pane').getByRole('button', { name: artifact.id, exact: true }).click()
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||
await expect(page.locator('.right-panel')).toHaveClass(/expanded/)
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'conversation')
|
||||
await expect(page.locator('.left-nav')).toBeHidden()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test(`opens a workspace document while context overlays the selected files pane at ${width}px`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
const state = await fixture(page)
|
||||
state.release()
|
||||
await expect(page.locator('.preview-pane .surface-view-title')).toHaveText(snapshot.spec.title!)
|
||||
await page.getByRole('button', { name: '打开文件', exact: true }).click()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await page.locator('.left-nav').getByRole('button', { name: /材料工作区/ }).click()
|
||||
const workspace = page.getByRole('dialog', { name: '材料工作区', exact: true })
|
||||
await workspace.getByRole('button', { name: artifact.name, exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.locator('.app-body')).toHaveAttribute('data-active-pane', 'files')
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('原始材料的阅读范围')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import type { ActivityCard, Artifact, Conversation, StreamEvent } from '../src/contracts'
|
||||
|
||||
test.skip(!process.env.WORK_COMPANION_BROWSER, 'Run explicitly against the task-owned UI preview')
|
||||
|
||||
const stamp = '2026-10-12T10:00:00Z'
|
||||
const project = { id: 'p1', name: '产品升级计划', createdAt: stamp, updatedAt: stamp }
|
||||
const conversations: Conversation[] = [
|
||||
{ id: 'c1', projectId: 'p1', title: '整理项目材料', createdAt: stamp, updatedAt: stamp },
|
||||
{ id: 'c2', projectId: 'p1', title: '讨论本周工作', createdAt: stamp, updatedAt: stamp },
|
||||
]
|
||||
const artifact: Artifact = { id: 'doc1', projectId: 'p1', name: '材料整理摘要.md', kind: 'markdown', size: 64, createdAt: stamp, updatedAt: stamp }
|
||||
function work(id: string, status: ActivityCard['status'], over: Partial<ActivityCard> = {}): ActivityCard {
|
||||
return { id, status, projectId: 'p1', conversationId: 'c1', kind: 'progress', title: '整理项目材料', createdAt: stamp, updatedAt: stamp, ...over }
|
||||
}
|
||||
const progress = work('work1', 'active', { body: [{ type: 'kv', kv: [
|
||||
['状态', '进行中'], ['正在做', '核对会议结论'], ['当前发现', '两份材料的时间安排需要核对'], ['剩余', '评审意见仍待核查'], ['材料键', 'private-material-key'],
|
||||
] }] })
|
||||
const confirmation = work('confirm1', 'action_required', { kind: 'confirm', title: '生成项目草稿',
|
||||
body: [{ type: 'markdown', markdown: '依据三份材料新增一份草稿,保留原件,不对外发送' }],
|
||||
actions: [{ id: 'confirm', label: '确认生成', style: 'primary' }, { id: 'cancel', label: '暂不生成' }],
|
||||
})
|
||||
|
||||
async function fixture(page: Page, initialCards: ActivityCard[] = [progress], fontSize = 14) {
|
||||
let cards = [...initialCards]
|
||||
let actionCount = 0
|
||||
let releaseAction: (() => void) | undefined
|
||||
let holdAction = false
|
||||
let sent: { text: string; attachmentIds: string[] } | undefined
|
||||
let uploadNumber = 0
|
||||
const errors: string[] = []
|
||||
page.on('pageerror', error => errors.push(error.message))
|
||||
await page.addInitScript(({ fontSize }) => {
|
||||
localStorage.setItem('as.conv', 'c1')
|
||||
localStorage.setItem('as.ui', JSON.stringify({ theme: 'light', fontSize, rightCollapsed: true, leftWidth: 260, rightWidth: 360 }))
|
||||
const streams: { url: string; emit: (event: unknown) => void }[] = []
|
||||
class TestEventSource {
|
||||
onopen: (() => void) | null = null
|
||||
onerror = null
|
||||
onmessage: ((event: { data: string }) => void) | null = null
|
||||
listeners: Record<string, ((event: { data: string }) => void)[]> = {}
|
||||
constructor(public url: string) { streams.push(this); queueMicrotask(() => this.onopen?.()) }
|
||||
addEventListener(type: string, callback: (event: { data: string }) => void) { (this.listeners[type] ??= []).push(callback) }
|
||||
emit(event: unknown) {
|
||||
const value = event as { type: string }
|
||||
for (const callback of this.listeners[value.type] ?? []) callback({ data: JSON.stringify(event) })
|
||||
}
|
||||
close() {}
|
||||
}
|
||||
Object.assign(window, { EventSource: TestEventSource, __workStreams: streams })
|
||||
}, { fontSize })
|
||||
await page.route('**/api/**', async route => {
|
||||
const request = route.request()
|
||||
const url = new URL(request.url())
|
||||
const path = url.pathname
|
||||
let body: unknown = []
|
||||
if (path === '/api/health') body = { ok: true }
|
||||
else if (path === '/api/me') body = { owner: 'reader', name: '林老师', auth: false, isAdmin: false }
|
||||
else if (path === '/api/projects') body = [project]
|
||||
else if (path === '/api/conversations') body = conversations
|
||||
else if (path === '/api/projects/p1/conversations' && request.method() === 'POST') body = conversations[1]
|
||||
else if (/^\/api\/conversations\/c[12]$/.test(path)) {
|
||||
const id = path.split('/').at(-1)!
|
||||
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 }
|
||||
} else if (path === '/api/conversations/c1/cards') body = { cards }
|
||||
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]
|
||||
else if (path === '/api/artifacts/doc1/content') {
|
||||
await route.fulfill({ status: 200, contentType: 'text/markdown', body: '# 材料整理摘要\n\n已整理两份材料,评审意见仍待核对' }); return
|
||||
} else if (path === '/api/settings') body = { user: { runtime: { mock: false } }, project: {}, effective: {}, schema: [] }
|
||||
else if (path === '/api/profiles') body = [{ id: 'default', name: '工作助手', model: {}, tools: { allow: [] }, context: { maxHistoryMessages: 20 }, hitl: { askOnAmbiguity: true }, systemPrompt: '' }]
|
||||
else if (path === '/api/projects/p1/uploads') body = { artifactId: 'upload' + ++uploadNumber, filename: '材料.txt', size: 16 }
|
||||
else if (/\/messages$/.test(path) && request.method() === 'POST') {
|
||||
sent = request.postDataJSON()
|
||||
body = { message: { id: 'sent', conversationId: 'c1', role: 'user', parts: [{ type: 'text', text: sent?.text ?? '' }], createdAt: stamp }, run: { id: 'run1', conversationId: 'c1', projectId: 'p1', kind: 'conversation', status: 'running', createdAt: stamp } }
|
||||
} else if (path === '/api/cards/confirm1/actions/confirm') {
|
||||
actionCount++
|
||||
if (holdAction) await new Promise<void>(resolve => { releaseAction = resolve })
|
||||
cards = cards.map(card => card.id === 'confirm1' ? { ...card, status: 'resolved' } : card)
|
||||
body = { card: cards.find(card => card.id === 'confirm1') }
|
||||
}
|
||||
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
})
|
||||
await page.goto('/')
|
||||
await expect(page.locator('.chat-title')).toHaveText('整理项目材料')
|
||||
if (initialCards.length) await expect(page.locator('[data-conversation-work]')).toBeVisible()
|
||||
return { errors, get actionCount() { return actionCount }, get sent() { return sent }, hold() { holdAction = true }, release() { releaseAction?.() },
|
||||
async emit(type: StreamEvent['type'], payload: unknown, seq: number) {
|
||||
await page.evaluate(({ type, payload, seq }) => {
|
||||
const win = window as typeof window & { __workStreams: { url: string; emit: (e: unknown) => void }[] }
|
||||
const stream = win.__workStreams.findLast(s => s.url.includes('conversationId=c1'))
|
||||
if (!stream) throw Error('conversation stream unavailable')
|
||||
stream.emit({ type, payload, seq, conversationId: 'c1', ts: '2026-10-12T10:00:00Z', v: 1 })
|
||||
}, { type, payload, seq })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('keeps real summary and details accessible with right pane closed', async ({ page }) => {
|
||||
const state = await fixture(page)
|
||||
await expect(page.locator('.right-panel')).toBeHidden()
|
||||
await expect(page.getByText('核对会议结论', { exact: true }).first()).toBeVisible()
|
||||
await expect(page.getByText('private-material-key')).toHaveCount(0)
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||
await page.locator('[data-conversation-work]').getByRole('button', { name: '查看执行记录', exact: true }).click()
|
||||
await expect(page.locator('[data-conversation-work]').getByText('private-material-key')).toBeVisible()
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'active' }, 11)
|
||||
await expect(page.locator('[data-conversation-work] .acard-status')).toHaveText('已完成')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps one expanded ordinary summary when the desktop work pane opens', 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()
|
||||
if (process.env.WORK_COMPANION_SCREENSHOT) await page.screenshot({ path: process.env.WORK_COMPANION_SCREENSHOT, fullPage: true })
|
||||
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!, {
|
||||
type: 'tabs', id: 'materials', tabs: { tabs: [
|
||||
{ id: 'first', label: '第一份材料', nodes: [{ type: 'markdown', markdown: '第一份材料的范围' }] },
|
||||
{ id: 'second', label: '第二份材料', nodes: [{ type: 'markdown', markdown: Array.from({ length: 70 }, (_, i) => `待核对事项 ${i + 1}`).join('\n\n') }] },
|
||||
] },
|
||||
}] })
|
||||
const state = await fixture(page, [tabbed, work('peer', 'active', { body: [] })])
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
const card = page.locator('.right-panel [data-card-id="work1"]')
|
||||
await card.getByRole('button', { name: '查看依据与范围', exact: true }).click()
|
||||
const tab = card.getByRole('tab', { name: '第二份材料', exact: true })
|
||||
await tab.click()
|
||||
await tab.focus()
|
||||
const details = card.locator('.acard-detail-body')
|
||||
await expect.poll(() => details.evaluate(element => element.clientHeight)).toBe(420)
|
||||
const physical = await details.evaluate(element => {
|
||||
Object.assign(window, { __workReading: element })
|
||||
element.scrollTop = 137
|
||||
return { height: element.clientHeight, scrollHeight: element.scrollHeight, top: element.scrollTop }
|
||||
})
|
||||
expect(physical.scrollHeight).toBeGreaterThan(physical.height)
|
||||
expect(physical.top).toBe(137)
|
||||
await state.emit('card.status', { cardId: 'work1', status: 'done' }, 12)
|
||||
await expect(card.locator('.acard-status')).toHaveText('已完成')
|
||||
await expect(tab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(await details.evaluate(element => ({
|
||||
retained: (window as typeof window & { __workReading: Element }).__workReading === element,
|
||||
top: element.scrollTop,
|
||||
}))).toEqual({ retained: true, top: 137 })
|
||||
expect(await tab.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('shares submitting feedback between conversation and work confirmations', 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('.right-panel').getByRole('button', { name: '确认生成', exact: true })).toBeDisabled()
|
||||
await expect(page.locator('[data-conversation-work]').getByRole('button', { name: '暂不生成', exact: true })).toBeDisabled()
|
||||
expect(state.actionCount).toBe(1)
|
||||
state.release()
|
||||
await expect(page.getByRole('button', { name: '确认生成', exact: true })).toHaveCount(0)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
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])
|
||||
await page.locator('[data-conversation-work]').getByText(artifact.name, { exact: true }).click()
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await expect(page.locator('.preview-pane-body')).toContainText('评审意见仍待核对')
|
||||
await state.emit('surface.updated', { identity: { surfaceId: 'new-page', owner: 'reader', pluginId: 'generative-ui', conversationId: 'c1' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新任务页面', nodes: [] } }, 11)
|
||||
await expect(page.locator('.preview-pane-name')).toHaveText(artifact.name)
|
||||
await page.getByRole('button', { name: '展开预览', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeHidden()
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
test('sends three attachments without text and states foreground queueing truthfully', async ({ page }) => {
|
||||
const state = await fixture(page, [])
|
||||
await page.locator('input[type=file]').first().setInputFiles([
|
||||
{ name: '会议纪要.txt', mimeType: 'text/plain', buffer: Buffer.from('会议内容') },
|
||||
{ name: '需求说明.txt', mimeType: 'text/plain', buffer: Buffer.from('需求内容') },
|
||||
{ name: '评审意见.txt', mimeType: 'text/plain', buffer: Buffer.from('评审内容') },
|
||||
])
|
||||
await expect(page.locator('.attach-chip')).toHaveCount(3)
|
||||
await page.getByRole('button', { name: '发送', exact: true }).click()
|
||||
await expect.poll(() => state.sent?.attachmentIds.length).toBe(3)
|
||||
expect(state.sent?.text).toBe('')
|
||||
await page.getByRole('textbox', { name: '工作目标或消息', exact: true }).fill('请优先核对时间安排')
|
||||
await expect(page.getByRole('button', { name: '加入队列', exact: true })).toBeEnabled()
|
||||
await page.getByRole('button', { name: '加入队列', exact: true }).click()
|
||||
await expect(page.getByText('请优先核对时间安排', { exact: true })).toBeVisible()
|
||||
await expect(page.locator('.input-status')).toContainText('排队')
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
|
||||
for (const width of [1440, 1024, 736, 390, 320]) {
|
||||
test(`keeps navigation and key actions usable at ${width}px with large UI type`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 960 })
|
||||
const state = await fixture(page, [progress], 18)
|
||||
await page.getByRole('button', { name: '打开工作', exact: true }).click()
|
||||
await expect(page.locator('.right-panel')).toBeVisible()
|
||||
await expect(page.locator('.right-panel').getByText('评审意见仍待核查')).toBeVisible()
|
||||
if (width < 1024) {
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
await expect(page.locator('.main')).toBeVisible()
|
||||
await page.getByRole('button', { name: '打开上下文', exact: true }).click()
|
||||
await expect(page.getByRole('button', { name: '新建对话', exact: true })).toBeVisible()
|
||||
await page.getByRole('button', { name: '返回对话', exact: true }).click()
|
||||
}
|
||||
const layout = await page.evaluate(() => ({ width: document.documentElement.clientWidth, scroll: document.documentElement.scrollWidth }))
|
||||
expect(layout.scroll).toBeLessThanOrEqual(layout.width)
|
||||
expect(state.errors).toEqual([])
|
||||
})
|
||||
}
|
||||
+91
-48
@@ -16,6 +16,8 @@ import { SettingsModal } from './components/SettingsModal'
|
||||
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { workspaceEntries } from './components/Workspaces'
|
||||
import { selectWorkbenchLayout } from './workbenchLayout'
|
||||
import './companion-shell.css'
|
||||
|
||||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||||
@@ -24,28 +26,66 @@ const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
|
||||
export function App() {
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => workspaceShortcuts(e)
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
|
||||
const ui = useStore(s => s.ui)
|
||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||
const previewOpenIntent = useStore(s => s.previewOpenIntent)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||||
const filesWide = rightTab === 'files' && previewOpen
|
||||
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
||||
const previewExpanded = filesWide && ui.previewExpanded
|
||||
const filesWide = rightTab === 'files' && ui.filesView === 'preview' && previewOpen
|
||||
const [viewportWidth, setViewportWidth] = useState(() => typeof window === 'undefined' ? 1440 : window.innerWidth)
|
||||
const [contextOpen, setContextOpen] = useState(false)
|
||||
const layout = selectWorkbenchLayout({ ...ui, width: viewportWidth, contextOpen, previewOpen })
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||
const [mustChangeOwner, setMustChangeOwner] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewportWidth(window.innerWidth)
|
||||
window.addEventListener('resize', onResize)
|
||||
return () => window.removeEventListener('resize', onResize)
|
||||
}, [])
|
||||
|
||||
// Explicit openings leave mobile context even if pane values match; metadata restores keep the reading choice.
|
||||
useEffect(() => { setContextOpen(false) }, [ui.rightCollapsed, rightTab, ui.filesView, ui.previewExpanded, previewOpenIntent])
|
||||
useEffect(() => { if (!layout.compact) setContextOpen(false) }, [layout.compact])
|
||||
|
||||
const returnToConversation = () => {
|
||||
setContextOpen(false)
|
||||
store.setUI(layout.compact ? { rightCollapsed: true, previewExpanded: false } : { previewExpanded: false })
|
||||
}
|
||||
const openContext = () => {
|
||||
if (!layout.compact) store.toggleLeft()
|
||||
else if (contextOpen) returnToConversation()
|
||||
else setContextOpen(true)
|
||||
}
|
||||
const openWork = () => {
|
||||
setContextOpen(false)
|
||||
store.openInbox()
|
||||
store.setUI({ inboxFilter: 'all', previewExpanded: false })
|
||||
}
|
||||
const openPending = () => { setContextOpen(false); store.focusPending() }
|
||||
const openFiles = () => { setContextOpen(false); store.openFiles() }
|
||||
const conversationOpened = () => { if (layout.compact) returnToConversation() }
|
||||
|
||||
// Keep conversation/context shortcuts available even when their pane is hidden.
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (authState === 'ok' && (e.metaKey || e.ctrlKey)) {
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'n') { e.preventDefault(); store.createTask().then(conversationOpened); return }
|
||||
if (key === 'b') { e.preventDefault(); openContext(); return }
|
||||
}
|
||||
workspaceShortcuts(e)
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [authState, layout.compact, contextOpen])
|
||||
|
||||
useEffect(() => {
|
||||
;(async () => {
|
||||
try {
|
||||
@@ -82,10 +122,10 @@ export function App() {
|
||||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||||
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
||||
const wide = side === 'right' && filesWide
|
||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||
drag.current = {
|
||||
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
||||
side, startX: e.clientX, startW: side === 'left' ? layout.leftWidth : layout.rightWidth, overshoot: false,
|
||||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||||
key,
|
||||
@@ -115,6 +155,18 @@ export function App() {
|
||||
window.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
const onDividerKey = (side: 'left' | 'right') => (e: React.KeyboardEvent) => {
|
||||
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||
e.preventDefault()
|
||||
const wide = side === 'right' && filesWide
|
||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||
const min = side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN
|
||||
const max = side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX
|
||||
const current = side === 'left' ? layout.leftWidth : layout.rightWidth
|
||||
const delta = (e.key === 'ArrowRight' ? 16 : -16) * (side === 'left' ? 1 : -1)
|
||||
store.setUI({ [key]: Math.min(max, Math.max(min, current + delta)) })
|
||||
}
|
||||
|
||||
if (authState === 'login') {
|
||||
return <LoginPage
|
||||
onAuthed={() => setAuthState('ok')}
|
||||
@@ -132,22 +184,20 @@ export function App() {
|
||||
|
||||
const tools = workspaceEntries()
|
||||
return (
|
||||
<div className="app">
|
||||
<TopBar />
|
||||
<div className="app-body">
|
||||
{!ui.leftCollapsed ? (
|
||||
<>
|
||||
<div className="left-nav" style={{ width: ui.leftWidth }}>
|
||||
<TaskSidebar />
|
||||
<div className={`app companion-app${layout.compact ? ' compact' : ''}`}>
|
||||
<TopBar compact={layout.compact} activePane={layout.pane} showReturn={layout.previewExpanded}
|
||||
onContext={openContext} onConversation={returnToConversation} onWork={openWork} onFiles={openFiles} />
|
||||
<div className="app-body" data-active-pane={layout.pane}>
|
||||
<aside className={`left-nav${layout.contextCollapsed ? ' collapsed' : ''}`} aria-label="上下文"
|
||||
hidden={!layout.showContext} style={{ width: layout.leftWidth }}>
|
||||
<div className="shell-context-content" hidden={layout.contextCollapsed}>
|
||||
<TaskSidebar onCollapse={layout.compact ? returnToConversation : store.toggleLeft}
|
||||
onConversationOpen={conversationOpened} onFilesOpen={openFiles} onWorkOpen={openPending} />
|
||||
</div>
|
||||
<div className="divider" onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft} title="拖动调整宽度;继续向左超过最小宽度后松开可收起;双击切换" />
|
||||
</>
|
||||
) : (
|
||||
<div className="left-nav collapsed" style={{ width: 52 }}>
|
||||
<div className="icon-only">
|
||||
<div className="icon-only" hidden={!layout.contextCollapsed}>
|
||||
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||||
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
|
||||
<button title="新建对话(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
|
||||
<button title="工作" onClick={openWork}><Icon name="inbox" />
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
{tools.map(entry => (
|
||||
@@ -155,31 +205,24 @@ export function App() {
|
||||
))}
|
||||
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
{layout.leftDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整上下文宽度" aria-orientation="vertical"
|
||||
aria-valuemin={LEFT_MIN} aria-valuemax={LEFT_MAX} aria-valuenow={Math.round(layout.leftWidth)}
|
||||
onKeyDown={onDividerKey('left')} onMouseDown={onDividerDown('left')} onDoubleClick={store.toggleLeft}
|
||||
title="拖动或用方向键调整宽度;双击收起" />}
|
||||
|
||||
<div className="main" hidden={previewExpanded}>
|
||||
<Conversation />
|
||||
</div>
|
||||
<main className="main" hidden={!layout.showConversation} aria-label="对话">
|
||||
<Conversation />
|
||||
</main>
|
||||
|
||||
{!ui.rightCollapsed ? (
|
||||
<>
|
||||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||||
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
||||
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
||||
<Workbench />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开右栏" onClick={store.toggleRight}>◧
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{layout.rightDivider && <div className="divider" role="separator" tabIndex={0} aria-label="调整工作或文件宽度" aria-orientation="vertical"
|
||||
aria-valuemin={filesWide ? PREVIEW_MIN : RIGHT_MIN} aria-valuemax={filesWide ? PREVIEW_MAX : RIGHT_MAX}
|
||||
aria-valuenow={Math.round(layout.rightWidth)} onKeyDown={onDividerKey('right')}
|
||||
onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动或用方向键调整宽度;双击收起" />}
|
||||
<aside className={`right-panel mk-panel-grow${layout.previewExpanded ? ' expanded' : ''}`}
|
||||
aria-label={rightTab === 'files' ? '文件' : '工作'} hidden={!layout.showRight} style={{ width: layout.rightWidth }}>
|
||||
<Workbench />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Workspaces />
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
.work-companion-chat .chat-header {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
gap: 12px;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
.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 {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: min(28rem, calc(100vw - 40px));
|
||||
z-index: 30;
|
||||
}
|
||||
.companion-settings-trigger, .companion-operation-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: var(--fs-ui-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
.companion-settings-trigger:hover, .companion-operation-trigger:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.companion-settings-body {
|
||||
padding: 16px;
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
.companion-setting { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; color: var(--text); font-size: var(--fs-ui-caption); }
|
||||
.companion-setting > span:first-child { flex: 1; min-width: 8rem; }
|
||||
.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 .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; }
|
||||
.companion-add-material { position: relative; display: inline-flex; align-items: center; gap: 4px; padding: 4px; cursor: pointer; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); }
|
||||
.companion-add-material:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.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-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; }
|
||||
.work-companion-chat .input-status-note { color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.companion-reconnect, .companion-upload-status, .companion-action-feedback { padding: 8px 0; color: var(--text-dim); font-size: var(--fs-ui-caption); line-height: 1.5; }
|
||||
.companion-upload-error { padding: 8px 0; color: var(--err-text); font-size: var(--fs-ui-caption); overflow-wrap: anywhere; }
|
||||
.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-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); }
|
||||
.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-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; }
|
||||
.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; }
|
||||
.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); }
|
||||
.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; }
|
||||
.work-companion-chat .msg-tools { gap: 4px; }
|
||||
@@ -0,0 +1,130 @@
|
||||
/* Responsive host chrome; the canonical theme and UI type scale stay in @ui. */
|
||||
.app.companion-app {
|
||||
height: 100dvh;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.companion-app .app-body { min-width: 0; overflow: hidden; }
|
||||
.companion-app .left-nav,
|
||||
.companion-app .main,
|
||||
.companion-app .right-panel { min-height: 0; max-width: 100%; overflow: hidden; }
|
||||
.companion-app .left-nav { transition: none; }
|
||||
.companion-app .left-nav[hidden],
|
||||
.companion-app .right-panel[hidden],
|
||||
.companion-app .shell-context-content[hidden],
|
||||
.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; }
|
||||
.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 .brand-mark {
|
||||
background: var(--primary-sidebar);
|
||||
color: var(--primary-sidebar-foreground);
|
||||
box-shadow: none;
|
||||
border-radius: var(--radius-sm) !important;
|
||||
}
|
||||
.shell-navigation { display: flex; align-items: center; gap: 4px; }
|
||||
.shell-nav-button,
|
||||
.shell-return {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
min-height: 32px;
|
||||
padding: 4px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-sidebar-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-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);
|
||||
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-projrow:hover { background: var(--bg-hover); }
|
||||
.companion-app .tsb-projrow-name { flex: 1; }
|
||||
.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-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-task-open {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
min-height: 40px;
|
||||
padding: 4px 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.companion-app .tsb-task-open > svg { flex: none; }
|
||||
.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; }
|
||||
.companion-app .tsb-row-menu {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: none;
|
||||
color: var(--text-dim);
|
||||
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-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-menu {
|
||||
min-width: min(216px, calc(100vw - 16px));
|
||||
max-width: min(360px, calc(100vw - 16px));
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.companion-app .tsb-mi { overflow-wrap: anywhere; }
|
||||
|
||||
.companion-app.compact .companion-topbar { flex-wrap: wrap; padding: 8px; }
|
||||
.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; }
|
||||
.companion-app.compact .shell-return { min-height: 36px; padding: 4px 8px; font-size: var(--fs-ui-sm); }
|
||||
.companion-app.compact .left-nav,
|
||||
.companion-app.compact .main,
|
||||
.companion-app.compact .right-panel { flex: 1 1 100%; width: 100%; }
|
||||
.companion-app.compact .tsb-tool { min-height: 40px; }
|
||||
.companion-app.compact .tsb-footer { padding: 8px 12px; }
|
||||
.companion-app.compact .tsb-row-acts,
|
||||
.companion-app.compact .tsb-task:focus-within .tsb-row-acts { display: none; }
|
||||
@@ -9,13 +9,13 @@ import { humanChangePasswordError } from './LoginPage'
|
||||
function Shell({ title, onClose, children }: { title: string; onClose: () => void; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="overlay" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
||||
<div className="modal ad-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
|
||||
<div className="modal admin-card" role="dialog" aria-label={title} style={{ maxWidth: 640 }}>
|
||||
<div className="modal-header">
|
||||
<b>{title}</b>
|
||||
<span className="spacer" />
|
||||
<button className="ad-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
|
||||
<button className="admin-mini" onClick={onClose} aria-label="关闭"><Icon name="x" size={13} /></button>
|
||||
</div>
|
||||
<div className="ad-body">{children}</div>
|
||||
<div className="admin-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -51,8 +51,8 @@ export function ChangePasswordModal({ onClose }: { onClose: () => void }) {
|
||||
|
||||
return (
|
||||
<Shell title="修改密码" onClose={onClose}>
|
||||
{ok ? <div className="ad-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<form className="ad-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
{ok ? <div className="admin-notice">密码已更新,其他设备的会话已注销。</div> : (
|
||||
<form className="admin-changepw" onSubmit={e => { e.preventDefault(); submit() }}>
|
||||
<label className="lg-label" htmlFor="changepw-username">用户名</label>
|
||||
<input id="changepw-username" className="lg-input" value={me?.owner ?? ''} readOnly autoComplete="username" />
|
||||
<label className="lg-label" htmlFor="changepw-current">当前密码</label>
|
||||
|
||||
@@ -59,7 +59,7 @@ export function ArtifactsPanel() {
|
||||
</div>
|
||||
)}
|
||||
{artifacts.length === 0
|
||||
? <div className="empty-note">暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载</div>
|
||||
? <div className="empty-note">暂无文件,可添加材料或在对话中生成文档</div>
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: groups.map(g => g.versions.length === 1
|
||||
|
||||
+163
-105
@@ -13,6 +13,8 @@ import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
|
||||
import { docBridge } from './Workbench'
|
||||
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, dedupeDeliverables, foldRunErrors, processSummary, humanError } from '../messageparts'
|
||||
import { ProcessTimeline } from './ProcessTimeline'
|
||||
import { ConversationWork } from './ConversationWork'
|
||||
import '../companion-conversation.css'
|
||||
|
||||
function hasFiles(dt: DataTransfer | null): boolean {
|
||||
return !!dt && Array.from(dt.types).includes('Files')
|
||||
@@ -28,9 +30,7 @@ function PartView({ part, live }: { part: Part; live?: boolean | null }) {
|
||||
return <SafeMarkdown className={live ? 'part-text cursor-blink' : 'part-text'} breaks
|
||||
markdown={markMissingArtifacts(part.text ?? '', id => !!findArtifact(id))} />
|
||||
case 'thinking':
|
||||
// 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。
|
||||
//(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。)
|
||||
return <ThinkingBlock part={part} idKey={part.text?.slice(0, 24) ?? 't'} defaultOpen={!!live} />
|
||||
return null
|
||||
case 'tool_call':
|
||||
return <span className="tool-chip">🛠 {part.toolCall?.name}</span>
|
||||
case 'tool_result': {
|
||||
@@ -105,18 +105,6 @@ function findCard(id: string) {
|
||||
return cardCache[id]
|
||||
}
|
||||
|
||||
// 思考块(Collapsible 契约):左线弱色,流式默认展开、终态默认收起;展开态记忆。
|
||||
function ThinkingBlock({ part, idKey, defaultOpen }: { part: Part; idKey: string; defaultOpen?: boolean }) {
|
||||
return (
|
||||
<div className="part-thinking">
|
||||
<Collapsible id={`think:${idKey}`} defaultOpen={defaultOpen} triggerClassName="part-thinking-summary"
|
||||
trigger={<><span className="part-thinking-chev">▸</span>思考过程</>}>
|
||||
<div className="part-thinking-body">{part.text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。
|
||||
function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: string; name: string; kind?: string }; updates?: number }) {
|
||||
const [menu, setMenu] = useState(false)
|
||||
@@ -189,10 +177,14 @@ function useElapsed(startedAt: number): string {
|
||||
// 流式状态行(独立组件:计时 hook 只在 live 行挂载期间运转)。
|
||||
function LiveBar({ live }: { live: LiveState }) {
|
||||
const elapsed = useElapsed(live.startedAt)
|
||||
const phase = livePhaseLabel(live)
|
||||
const label = phase.startsWith('正在编写') ? '正在准备操作'
|
||||
: phase.startsWith('正在运行') ? '正在处理'
|
||||
: phase === '思考中' ? '正在梳理' : phase
|
||||
return (
|
||||
<span className="live-bar">
|
||||
<span className="live-dot" />
|
||||
<span className="mk-sweep">{livePhaseLabel(live)}</span>
|
||||
<span className="mk-sweep">{label}</span>
|
||||
<span className="live-elapsed">已工作 {elapsed}</span>
|
||||
</span>
|
||||
)
|
||||
@@ -211,15 +203,14 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
const split = useMemo(() => splitTimeline(parts, finished), [parts, finished])
|
||||
const showProcess = showProcessInline(live ?? null, autoCollapse)
|
||||
const memKey = `process:${msg.runId || msg.id}`
|
||||
const touched = !!(msg.runId && touchedProcess.has(msg.runId))
|
||||
const touched = touchedProcess.has(memKey)
|
||||
const [open, setOpen] = useState(() => {
|
||||
if (live && !touched) return true
|
||||
if (touched) return getPersistedOpen(memKey)
|
||||
return showProcess
|
||||
})
|
||||
useEffect(() => {
|
||||
if (live && msg.runId && !touchedProcess.has(msg.runId)) setOpen(true)
|
||||
}, [live, msg.runId])
|
||||
if (!touchedProcess.has(memKey)) setOpen(showProcess)
|
||||
}, [memKey, showProcess])
|
||||
const extras = live ? [] : citedArtifactCards(split.final)
|
||||
const cards = live ? [] : dedupeDeliverables(split.final)
|
||||
const lastText = (() => {
|
||||
@@ -229,13 +220,13 @@ 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 headline = live ? livePhaseLabel(live) : processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)
|
||||
const headline = live ? '执行记录' : `执行记录 · ${processSummary(toolCount, cards.length, run?.createdAt, run?.endedAt)}`
|
||||
const timeline = (
|
||||
<ProcessTimeline parts={split.timeline} live={!!live} idKey={msg.id}
|
||||
renderPart={(part, liveText) => <PartView part={part} live={liveText} />} />
|
||||
)
|
||||
const toggleProcess = (next: boolean) => {
|
||||
if (msg.runId) touchedProcess.add(msg.runId)
|
||||
touchedProcess.add(memKey)
|
||||
setPersistedOpen(memKey, next)
|
||||
setOpen(next)
|
||||
}
|
||||
@@ -246,7 +237,7 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
<span>助手</span>
|
||||
{live && <LiveBar live={live} />}
|
||||
</div>
|
||||
{split.timeline.length > 0 && (
|
||||
{split.timeline.some(part => part.type !== 'thinking') && (
|
||||
<div className="process-collapse">
|
||||
<Collapsible id={memKey} open={open} onToggle={toggleProcess} triggerClassName="process-collapse-summary" unmountDelay={300}
|
||||
trigger={<>
|
||||
@@ -268,7 +259,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat
|
||||
{extras.map(art => (
|
||||
<DeliverableCard key={art.id} artifact={{ artifactId: art.id, name: art.name, kind: art.kind }} />
|
||||
))}
|
||||
{live && <LiveAgentNotes live={live} />}
|
||||
{live && Object.keys(live.agentNotes).length > 0 && <Collapsible id={`agent-notes:${live.runId}`}
|
||||
triggerClassName="process-collapse-summary" trigger={<span>协作记录</span>}><LiveAgentNotes live={live} /></Collapsible>}
|
||||
{!live && ctxObs && <ContextObsView obs={ctxObs} />}
|
||||
{convId && <MessageTools msg={msg} convId={convId} />}
|
||||
</div>
|
||||
@@ -282,25 +274,32 @@ function SessionNoteField({ convId, note }: { convId: string; note: string }) {
|
||||
<input className="search-input session-note" value={value} placeholder="本会话要求"
|
||||
title="附在上下文末尾,不改系统前缀"
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => store.openConversation(convId)) }} />
|
||||
onBlur={() => { if (value !== note) void api.setSessionNote(convId, value).then(() => {
|
||||
if (store.getState().currentConvId === convId) return store.openConversation(convId)
|
||||
}) }} />
|
||||
)
|
||||
}
|
||||
|
||||
function MessageTools({ msg, convId }: { msg: Message; convId: string }) {
|
||||
const text = msg.parts.filter(p => p.type === 'text').map(p => p.text ?? '').join('\n')
|
||||
const refresh = () => { void store.openConversation(convId) }
|
||||
const refresh = () => { if (store.getState().currentConvId === convId) void store.openConversation(convId) }
|
||||
return (
|
||||
<div className="msg-tools">
|
||||
<button className="btn ghost" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||||
</button>
|
||||
{msg.role === 'assistant' && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
|
||||
)}
|
||||
{msg.variantGroup && msg.excluded && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||||
)}
|
||||
<button className="companion-message-copy" type="button" onClick={() => { void navigator.clipboard.writeText(text) }}>复制</button>
|
||||
<Collapsible id={`message-operations:${convId}:${msg.id}`} trigger="消息操作" triggerClassName="companion-message-copy">
|
||||
<div className="companion-message-actions">
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.excludeMessage(msg.id, !msg.excluded).then(refresh) }}>
|
||||
{msg.excluded ? '纳入上下文' : '排除出上下文'}
|
||||
</button>
|
||||
{msg.role === 'assistant' && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.regenerate(convId, msg.id).then(refresh) }}>重新生成</button>
|
||||
)}
|
||||
{msg.variantGroup && msg.excluded && (
|
||||
<button className="btn ghost" type="button" onClick={() => { void api.selectVariant(msg.id).then(refresh) }}>使用此候选</button>
|
||||
)}
|
||||
{msg.role === 'user' && <RewindControl convId={convId} messageId={msg.id} />}
|
||||
</div>
|
||||
</Collapsible>
|
||||
{msg.variantGroup && <span className="process-collapse-meta">候选 {(msg.variantIndex ?? 0) + 1}</span>}
|
||||
</div>
|
||||
)
|
||||
@@ -349,16 +348,32 @@ function LiveAgentNotes({ live }: { live: LiveState }) {
|
||||
|
||||
function HITLCardView({ card }: { card: HITLCard }) {
|
||||
const [free, setFree] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const actionState = useStore(s => s.cardActionStates?.[`hitl:${card.id}`])
|
||||
const submitting = actionState !== undefined
|
||||
// wfcompile.doScript 使用固定首行 + 脚本预览;其他多行问题可能包含
|
||||
// 用户必须理解的业务后果,保持原样,不凭内容猜测其信息层级。
|
||||
const scriptPrefix = '运行这段脚本?\n'
|
||||
const hasScriptPreview = card.kind === 'confirm' && card.question.startsWith(scriptPrefix)
|
||||
const question = hasScriptPreview ? scriptPrefix.trimEnd() : card.question
|
||||
const detail = [hasScriptPreview ? card.question.slice(scriptPrefix.length) : '', card.detail]
|
||||
.filter(value => value?.trim()).join('\n\n')
|
||||
const answer = async (resp: { choiceId?: string; freeText?: string }) => {
|
||||
setSubmitting(true)
|
||||
if (store.getState().cardActionStates?.[`hitl:${card.id}`]) return
|
||||
await store.resolveHITL(card.id, resp)
|
||||
setSubmitting(false)
|
||||
}
|
||||
return (
|
||||
<div className={`hitl-card${card.kind === 'confirm' ? ' wait' : ''}${card.kind === 'choice' || card.kind === 'input' ? ' ask' : ''}`}>
|
||||
<div className="hitl-q"><Icon name="check" size={15} />{card.question}</div>
|
||||
{card.detail && <pre className="hitl-detail">{card.detail}</pre>}
|
||||
<div className="hitl-q"><Icon name="check" size={15} />{question}</div>
|
||||
{card.blocked && <div className="companion-action-feedback" role="status">
|
||||
操作受限:{card.blockReason || '当前操作条件尚未满足'}
|
||||
</div>}
|
||||
{detail && <div className="process-collapse"><Collapsible id={`hitl-details:${card.conversationId}:${card.id}`}
|
||||
triggerClassName="process-collapse-summary" trigger={<><span className="process-collapse-chev">▸</span>操作详情</>}>
|
||||
<pre className="hitl-detail">{detail}</pre>
|
||||
</Collapsible></div>}
|
||||
{actionState && <div className="companion-action-feedback" role="status">
|
||||
{actionState === 'unknown' ? '正在核实处理结果' : '正在提交你的选择'}
|
||||
</div>}
|
||||
{card.choices && (
|
||||
<div className="hitl-choices">
|
||||
{card.choices.map(c => (
|
||||
@@ -373,7 +388,7 @@ function HITLCardView({ card }: { card: HITLCard }) {
|
||||
{card.allowFreeInput && (
|
||||
<div className="hitl-free">
|
||||
<input className="search-input" value={free} onChange={e => setFree(e.target.value)}
|
||||
placeholder={card.freeInputHint || '自由输入…'} onKeyDown={e => { if (e.key === 'Enter' && free.trim()) answer({ freeText: free.trim() }) }} />
|
||||
disabled={submitting} placeholder={card.freeInputHint || '补充你的要求'} onKeyDown={e => { if (e.key === 'Enter' && free.trim() && !submitting) void answer({ freeText: free.trim() }) }} />
|
||||
<button className="btn ghost" disabled={!free.trim() || submitting} onClick={() => answer({ freeText: free.trim() })}>发送</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -450,6 +465,13 @@ export function NoticeMessage({ msg }: { msg: Message }) {
|
||||
}
|
||||
|
||||
export function Conversation() {
|
||||
const conversationId = useStore(s => s.currentConvId)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
// 本地草稿、附件、折叠设置与滚动跟随属于当前会话,切换时一起重置。
|
||||
return <ConversationSession key={`${projectId}:${conversationId}`} />
|
||||
}
|
||||
|
||||
function ConversationSession() {
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const conversation = useStore(s => s.tasks.find(c => c.id === s.currentConvId))
|
||||
const messages = useStore(s => s.messages)
|
||||
@@ -462,17 +484,25 @@ export function Conversation() {
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const profiles = useStore(s => s.profiles)
|
||||
const cards = useStore(s => s.cards)
|
||||
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
|
||||
|
||||
const runtimeMock = useStore(s => s.runtimeMock)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const cardActionStates = useStore(s => s.cardActionStates)
|
||||
const [text, setText] = useState('')
|
||||
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
|
||||
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
|
||||
const [dragDepth, setDragDepth] = useState(0)
|
||||
const [uploading, setUploading] = useState(0)
|
||||
const [uploadError, setUploadError] = useState('')
|
||||
const composeActive = useRef(true)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const bodyRef = useRef<HTMLDivElement>(null)
|
||||
const stickRef = useRef(true)
|
||||
const [showJump, setShowJump] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
composeActive.current = true
|
||||
return () => { composeActive.current = false }
|
||||
}, [])
|
||||
|
||||
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
|
||||
cardCache = Object.fromEntries(cards.map(c => [c.id, c]))
|
||||
|
||||
@@ -492,8 +522,30 @@ export function Conversation() {
|
||||
bodyRef.current?.scrollTo({ top: bodyRef.current.scrollHeight })
|
||||
}, [messages.length, live?.items, live?.phase, activeHitl.length])
|
||||
|
||||
const composeIsCurrent = () => {
|
||||
const state = store.getState()
|
||||
return composeActive.current && state.currentConvId === currentConvId && state.currentProjectId === currentProjectId
|
||||
}
|
||||
const addMaterials = async (files: File[]) => {
|
||||
if (!files.length) return
|
||||
setUploadError('')
|
||||
setUploading(count => count + files.length)
|
||||
for (const file of files) {
|
||||
if (!composeIsCurrent()) return
|
||||
const id = await store.uploadAttachment(file)
|
||||
if (!composeIsCurrent()) return
|
||||
setUploading(count => Math.max(0, count - 1))
|
||||
if (id) {
|
||||
setAttachmentIds(ids => [...ids, id])
|
||||
setAttachmentNames(names => [...names, file.name])
|
||||
} else {
|
||||
setUploadError(message => [message, `「${file.name}」未能添加,请重新选择文件`].filter(Boolean).join(';'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const send = async () => {
|
||||
if (!text.trim() && attachmentIds.length === 0) return
|
||||
if (uploading > 0 || (!text.trim() && attachmentIds.length === 0)) return
|
||||
const t = text
|
||||
const ids = attachmentIds
|
||||
const names = attachmentNames
|
||||
@@ -507,54 +559,54 @@ export function Conversation() {
|
||||
|
||||
if (!currentConvId) {
|
||||
return (
|
||||
<div className="chat">
|
||||
<div className="chat-body"><div className="empty-state">
|
||||
<div style={{ fontSize: 40 }}>⌘</div>
|
||||
<div>选择或创建一个会话开始</div>
|
||||
{runtimeMock
|
||||
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
|
||||
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
|
||||
<div className="chat work-companion-chat">
|
||||
<div className="chat-body"><div className="empty-state companion-empty">
|
||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||
<div className="companion-empty-title">从一件需要完成的工作开始</div>
|
||||
<div>告诉助手你的目标,或准备好需要一起阅读的材料</div>
|
||||
<button type="button" className="btn" onClick={() => { void store.createTask() }}>开始新对话</button>
|
||||
</div></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
||||
<div className={`chat work-companion-chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
|
||||
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
|
||||
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
|
||||
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
|
||||
onDrop={async e => {
|
||||
onDrop={e => {
|
||||
if (!e.dataTransfer?.files?.length) return
|
||||
e.preventDefault()
|
||||
setDragDepth(0)
|
||||
for (const f of Array.from(e.dataTransfer.files)) {
|
||||
const id = await store.uploadAttachment(f)
|
||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||
}
|
||||
void addMaterials(Array.from(e.dataTransfer.files))
|
||||
}}>
|
||||
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
|
||||
<div className="chat-header">
|
||||
<span className="project-badge" title="当前项目">{projectName}</span>
|
||||
<div className="chat-title">{conversation?.title ?? '会话'}</div>
|
||||
<select className="effect-badge" value={conversation?.profileId || 'default'} title="切换会话画像"
|
||||
<Collapsible id={`conversation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-settings-trigger"
|
||||
trigger={<><Icon name="settings" size={14} /><span>会话设置</span></>} bodyClassName="companion-settings-body">
|
||||
<label className="companion-setting"><span>工作方式</span>
|
||||
<select value={conversation?.profileId || 'default'} aria-label="工作方式"
|
||||
onChange={async e => {
|
||||
if (!currentConvId) return
|
||||
await api.setConversationProfile(currentConvId, e.target.value)
|
||||
await store.openConversation(currentConvId)
|
||||
if (store.getState().currentConvId === currentConvId) await store.openConversation(currentConvId)
|
||||
}}>
|
||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||
</select>
|
||||
<button className="btn ghost" style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}
|
||||
</label>
|
||||
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
|
||||
title={conversation?.allowCrossRead === false
|
||||
? '本会话不读取其他会话记录(点击开启)'
|
||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||
<Icon name="users" size={13} />跨读{conversation?.allowCrossRead === false ? '·关' : '·开'}
|
||||
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
|
||||
</button>
|
||||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{!connected && <span className="conn-label">重连中</span>}
|
||||
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
|
||||
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
|
||||
</Collapsible>
|
||||
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
</div>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}
|
||||
@@ -584,10 +636,10 @@ export function Conversation() {
|
||||
</div>
|
||||
<div className="bubble">{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}</div>
|
||||
{currentConvId && <MessageTools msg={m} convId={currentConvId} />}
|
||||
{currentConvId && <RewindControl convId={currentConvId} messageId={m.id} />}
|
||||
{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),
|
||||
}} />
|
||||
))}
|
||||
@@ -600,10 +652,16 @@ export function Conversation() {
|
||||
{showLiveBubble(live?.runId ?? null, messages.filter(m => m.role === 'assistant' && m.runId).map(m => m.runId as string)) && live &&
|
||||
<AssistantMessage key={`live-${live.runId}`} msg={liveToMessage(live)} live={live} />}
|
||||
{activeHitl.map(c => <HITLCardView key={c.id} card={c} />)}
|
||||
{messages.length === 0 && <div className="empty-state">
|
||||
<ConversationWork />
|
||||
{messages.length === 0 && !live && cards.length === 0 && activeHitl.length === 0 && <div className="empty-state companion-empty">
|
||||
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
|
||||
<div>发送消息开始对话</div>
|
||||
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
|
||||
<div className="companion-empty-title">这次想完成什么</div>
|
||||
<div>说说目标,也可以先添加材料</div>
|
||||
<div className="companion-examples" aria-label="可参考的工作目标">
|
||||
{['帮我整理这些材料中的重点和缺项', '一起比较这两个方案,说明各自适合的情况', '帮我把已有想法整理成一份草稿'].map(example => (
|
||||
<button type="button" className="companion-example" key={example} onClick={() => { setText(example); inputRef.current?.focus() }}>{example}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{showJump && (
|
||||
@@ -616,59 +674,59 @@ export function Conversation() {
|
||||
|
||||
<div className="input-bar">
|
||||
<OutboxPanel />
|
||||
{!connected && <div className="companion-reconnect" role="status">正在重新连接,最新进展待确认</div>}
|
||||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||||
{(busy || bgRunning > 0) && (
|
||||
<div className="input-status" data-testid="input-status">
|
||||
<span className="live-dot" />
|
||||
{busy
|
||||
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
||||
: <span>{bgRunning} 个后台任务进行中</span>}
|
||||
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
||||
? <span className="mk-sweep">助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''}</span>
|
||||
: <span>{bgRunning} 项后台工作</span>}
|
||||
<span className="input-status-note">{busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}</span>
|
||||
</div>
|
||||
)}
|
||||
{attachmentNames.length > 0 && (
|
||||
<div className="attachments">
|
||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||
<span style={{ cursor: 'pointer', display: 'inline-flex' }} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></span>
|
||||
<button type="button" className="companion-remove-material" aria-label={`移除材料 ${n}`} onClick={() => { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}><Icon name="x" size={11} /></button>
|
||||
</span>)}
|
||||
</div>
|
||||
)}
|
||||
{uploading > 0 && <div className="companion-upload-status" role="status">正在添加 {uploading} 份材料,完成后即可发送</div>}
|
||||
{uploadError && <div className="companion-upload-error" role="alert">{uploadError}</div>}
|
||||
<Collapsible id={`operation-settings:${currentProjectId}:${currentConvId}`} triggerClassName="companion-operation-trigger"
|
||||
trigger={<><Icon name="settings" size={13} /><span>操作设置</span></>} bodyClassName="companion-operation-body">
|
||||
<label className="companion-setting"><span>操作权限</span><select aria-label="操作权限" value={conversation?.permissionMode || 'default'} onChange={e => {
|
||||
const mode = e.target.value
|
||||
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') void store.setPermissionMode(currentConvId, mode)
|
||||
}}>
|
||||
<option value="default">默认</option><option value="plan">计划</option><option value="auto_edit">自动改文件</option>
|
||||
</select></label>
|
||||
<p className="companion-setting-note">计划模式只进行读取与规划;自动改文件模式允许文件修改。重要操作仍按当前权限规则确认</p>
|
||||
</Collapsible>
|
||||
{text.startsWith('/') && <CommandMenu text={text} onPick={c => setText(c)} />}
|
||||
<div className="input-row">
|
||||
<label className="mode-select" title="本会话的权限模式">
|
||||
<span>权限</span>
|
||||
<select value={conversation?.permissionMode || 'default'} onChange={e => {
|
||||
const mode = e.target.value
|
||||
if (mode === 'default' || mode === 'plan' || mode === 'auto_edit') {
|
||||
if (currentConvId) void store.setPermissionMode(currentConvId, mode)
|
||||
}
|
||||
}}>
|
||||
<option value="default">默认</option>
|
||||
<option value="plan">计划</option>
|
||||
<option value="auto_edit">自动改文件</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="icon-btn" title="上传附件(文档解析后注入上下文)">
|
||||
<Icon name="paperclip" size={16} /><input type="file" multiple style={{ display: 'none' }} onChange={async e => {
|
||||
for (const f of Array.from(e.target.files ?? [])) {
|
||||
const id = await store.uploadAttachment(f)
|
||||
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
|
||||
}
|
||||
e.target.value = ''
|
||||
}} />
|
||||
</label>
|
||||
<textarea rows={1} value={text} placeholder={hasOpenConfirm(cards) ? '有待确认的命令——请先处理上方确认卡'
|
||||
: busy ? '继续输入以排队'
|
||||
: artifacts.length > 0 ? '输入消息…(/ 命令,@knowledge 检索注入)' : '输入消息…(/ 命令)'}
|
||||
<textarea ref={inputRef} rows={2} value={text} aria-label="工作目标或消息" placeholder={hasOpenConfirm(cards) ? '有一项操作等你确认,请先查看上方说明'
|
||||
: busy ? '继续补充,新消息会在当前回合结束后发送'
|
||||
: '告诉助手你的目标,或添加需要一起阅读的材料'}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() } }} />
|
||||
<div className="companion-compose-actions">
|
||||
<label className="companion-add-material" title="添加材料">
|
||||
<Icon name="paperclip" size={16} /><span>添加材料</span><input type="file" multiple className="companion-file-input" aria-label="添加材料" onChange={e => {
|
||||
const files = Array.from(e.currentTarget.files ?? [])
|
||||
e.currentTarget.value = ''
|
||||
void addMaterials(files)
|
||||
}} />
|
||||
</label>
|
||||
<span className="companion-input-hint">Enter 发送 · Shift+Enter 换行</span>
|
||||
<span className="companion-compose-spacer" />
|
||||
{showStop && (
|
||||
<button className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||||
<button type="button" className="btn danger" onClick={() => store.cancelActive()}>停止</button>
|
||||
)}
|
||||
<button className="btn" onClick={send} disabled={!text.trim() && attachmentIds.length === 0}>{busy ? '排队' : '发送'}</button>
|
||||
<button type="button" className="btn" onClick={send} disabled={uploading > 0 || (!text.trim() && attachmentIds.length === 0)}>{busy ? '加入队列' : '发送'}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -697,7 +755,7 @@ function RewindControl({ convId, messageId }: { convId: string; messageId: strin
|
||||
try {
|
||||
await api.rewindFiles(convId, messageId)
|
||||
setOpen(false)
|
||||
await store.openTask(convId)
|
||||
if (store.getState().currentConvId === convId) await store.openTask(convId)
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { ActivityCardView, Collapsible, activitySummary, pendingCount, type ActivityCard } from '@ui'
|
||||
import { store, useStore } from '../store'
|
||||
import { docBridge } from './Workbench'
|
||||
|
||||
/** 对话与辅助工作栏共享对象、动作和唯一文件预览,不另持有异步状态。 */
|
||||
export function ConversationWork() {
|
||||
const cards = useStore(s => s.cards)
|
||||
const conversationId = useStore(s => s.currentConvId)
|
||||
const projectId = useStore(s => s.currentProjectId)
|
||||
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 { current, required, focused, history } = useMemo(() => {
|
||||
const current = cards.filter(card => card.status !== 'dismissed'
|
||||
&& card.conversationId === conversationId && card.projectId === projectId)
|
||||
const required: ActivityCard[] = []
|
||||
const remaining: ActivityCard[] = []
|
||||
for (const card of current) {
|
||||
// A confirmation already visible at its user message has one main action
|
||||
// path in the conversation; assistant-anchored cards still need this path.
|
||||
if (card.kind === 'confirm' && card.anchorMessageId
|
||||
&& messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue
|
||||
const summary = activitySummary(card)
|
||||
if (card.status === 'action_required' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card)
|
||||
else remaining.push(card)
|
||||
}
|
||||
// Preserve the host's newest-first order for completed results and history.
|
||||
const focused = remaining.find(card => card.pinned && card.status === 'active')
|
||||
?? remaining.find(card => card.status === 'active')
|
||||
?? 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
|
||||
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
|
||||
const summary = [pending ? `${pending} 项待处理` : '', active ? `${active} 项工作` : '', results ? `${results} 项结果` : ''].filter(Boolean).join(' · ')
|
||||
const callbacks = {
|
||||
onAction: (card: ActivityCard, actionId: string) => { void store.cardAction(card, actionId) },
|
||||
actionState: (card: ActivityCard) => actionStates?.[card.id],
|
||||
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
||||
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
||||
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
|
||||
onArchive: (card: ActivityCard) => { void store.cardToProject(card) },
|
||||
}
|
||||
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
|
||||
|
||||
return (
|
||||
<section className="conversation-work" data-conversation-work aria-label="工作进展">
|
||||
<div className="conversation-work-heading"><span>工作进展</span><span>{summary || '已处理的工作'}</span></div>
|
||||
<div className="conversation-work-current">
|
||||
{required.map(view)}
|
||||
{focused && <Collapsible id={`conversation-current:${conversationId}:${focused.id}`} open={summaryOpen} onToggle={setSummaryOpen}
|
||||
triggerClassName="conversation-work-more" trigger={<><span className="process-collapse-chev">▸</span>{focused.title} · 查看摘要</>}>
|
||||
{view(focused)}
|
||||
</Collapsible>}
|
||||
</div>
|
||||
{history.length > 0 && <Collapsible id={`conversation-work:${projectId}:${conversationId}`}
|
||||
triggerClassName="conversation-work-more"
|
||||
trigger={<><span className="process-collapse-chev">▸</span>更多工作与成果 · {history.length} 项</>}>
|
||||
<div className="conversation-work-history">{history.map(view)}</div>
|
||||
</Collapsible>}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// 工作时间线:思考、对用户的解说、同类工具合并成一行。运行中展开;
|
||||
// 终轮结束后由调用方套进「已工作 X」折叠。
|
||||
// 执行记录按工作动作分组;展开状态由调用方控制。
|
||||
import type { ReactNode } from 'react'
|
||||
import { Collapsible, ToolBlock } from '@ui'
|
||||
import { ToolBlock } from '@ui'
|
||||
import type { Part } from '../contracts'
|
||||
import { formatDuration, humanSize } from '../messageparts'
|
||||
import { timelineEntries, toolGroupLabel, toolTail, type ToolGroupItem } from '../toolkind'
|
||||
import { timelineEntries, toolGroupLabel, type ToolGroupItem } from '../toolkind'
|
||||
|
||||
export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
parts: Part[]
|
||||
@@ -16,20 +15,8 @@ export function ProcessTimeline({ parts, live, idKey, renderPart }: {
|
||||
return (
|
||||
<div className="tl">
|
||||
{rows.map((row, i) => {
|
||||
if (row.kind === 'thinking') {
|
||||
const dur = formatDuration(row.durationMs)
|
||||
const label = row.active ? '思考中' : (dur ? `思考 · 持续了 ${dur}` : '思考')
|
||||
return (
|
||||
<div className="part-thinking" key={`${idKey}:th:${i}`}>
|
||||
<Collapsible id={`think:${idKey}:${i}:${row.active ? 'live' : 'done'}`} defaultOpen={row.active} key={row.active ? 'open' : 'shut'}
|
||||
triggerClassName="part-thinking-summary"
|
||||
trigger={<><span className="part-thinking-chev">▸</span>
|
||||
<span className={row.active ? 'mk-sweep' : ''}>{label}</span></>}>
|
||||
<div className="part-thinking-body">{row.text}</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// 模型内部思考不是用户工作内容,也不属于可展开的执行记录。
|
||||
if (row.kind === 'thinking') return null
|
||||
if (row.kind === 'text') {
|
||||
return <div className="part" key={`${idKey}:tx:${i}`}>{renderPart({ type: 'text', text: row.text }, false)}</div>
|
||||
}
|
||||
@@ -70,7 +57,6 @@ function sizeLabel(n: number): string {
|
||||
|
||||
function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
||||
const composing = item.phase === 'composing'
|
||||
const tail = item.running ? toolTail(item.arguments, item.output) : ''
|
||||
const dur = !item.running ? formatDuration(item.durationMs) : ''
|
||||
const detail = composing ? undefined : (dur ? `${item.headline} · ${dur}` : item.headline)
|
||||
return (
|
||||
@@ -88,7 +74,6 @@ function ToolCallBlock({ item }: { item: ToolGroupItem }) {
|
||||
{item.arguments && <pre className="tool-preview">{item.arguments}</pre>}
|
||||
{item.output && <pre className="tool-preview">{item.output}</pre>}
|
||||
</ToolBlock>
|
||||
{tail && <pre className="tool-live-preview">{tail}</pre>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface TreeItem { kind: string; id: string; title: string; state?: string }
|
||||
interface RelationEntry { layer: string; edgeId?: string; kind?: string; type?: string; state: string; depth?: number; from: any; to: any; conf?: number }
|
||||
interface EventDTO { seq: number; at: string; op: string; actor: string; source: string; target: { kind: string; id: string }; detail: string; operationId: string; disclosure: string }
|
||||
interface OpGroup { operationId: string; actor: string; at: string; events: EventDTO[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface ObjectDetail { kind: string; id: string; title?: string; state?: string; versions?: any[]; formal?: any; labels?: any; batch?: string; ver?: number; parentId?: string; childCount?: number; sliceCount?: number; mounts?: string[]; relations: RelationEntry[]; recentEvents: EventDTO[]; coverage?: CoverageItem[] }
|
||||
interface HistoryData { projectId: string; watermark: number; atSeq: number; object?: any; formal?: any; formalMissing?: string; countOnlySnapshots?: number; coverage?: CoverageItem[]; empty?: boolean }
|
||||
|
||||
// ---- 工作区主组件 ----
|
||||
@@ -148,102 +148,107 @@ function MaterialsTab({ pid }: { pid: string }) {
|
||||
<button className={`tab${kind === 'notes' ? ' active' : ''}`} onClick={() => setKind('notes')}>笔记树</button>
|
||||
<button className={`tab${kind === 'docs' ? ' active' : ''}`} onClick={() => setKind('docs')}>文档树</button>
|
||||
</div>
|
||||
<TreeList pid={pid} kind={kind} />
|
||||
<TreeList key={`${pid}:${kind}`} pid={pid} kind={kind} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeList({ pid, kind }: { pid: string; kind: 'notes' | 'docs' }) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [err, setErr] = useState('')
|
||||
const [selected, setSelected] = useState<TreeItem | null>(null)
|
||||
const [detail, setDetail] = useState<ObjectDetail | null>(null)
|
||||
// docs 树是层级结构(rdm.inspect.tree parent=<docId> 才返回章节):文档节点
|
||||
// 点击展开子章节,不直接查对象详情(doc 不在 inspect.object 支持面)。
|
||||
const [children, setChildren] = useState<Record<string, TreeItem[]>>({})
|
||||
const [detailError, setDetailError] = useState('')
|
||||
const genRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
useEffect(() => () => { genRef.current++ }, [])
|
||||
|
||||
const selectNode = useCallback((item: TreeItem) => {
|
||||
const gen = ++genRef.current
|
||||
setShell(null); setErr(''); setSelected(null); setDetail(null); setChildren({})
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setShell(s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}, [pid, kind])
|
||||
|
||||
const loadMore = () => {
|
||||
if (!shell?.nextCursor) return
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, cursor: shell.nextCursor })
|
||||
.then(s => { if (gen === genRef.current) setShell(prev => prev ? { ...s, items: [...prev.items, ...s.items] } : s) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const toggleDoc = (item: TreeItem) => {
|
||||
if (children[item.id]) {
|
||||
setChildren(prev => { const next = { ...prev }; delete next[item.id]; return next })
|
||||
return
|
||||
}
|
||||
const gen = genRef.current
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind: 'docs', parent: item.id, cursor: '' })
|
||||
.then(s => { if (gen === genRef.current) setChildren(prev => ({ ...prev, [item.id]: s.items })) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
const selectNode = (item: TreeItem) => {
|
||||
if (item.kind === 'doc') { toggleDoc(item); return }
|
||||
setSelected(item); setDetail(null)
|
||||
const gen = genRef.current
|
||||
setSelected(item); setDetail(null); setDetailError('')
|
||||
api.rdmCommand<ObjectDetail>('rdm.inspect.object', { projectId: pid, kind: item.kind, id: item.id })
|
||||
.then(d => { if (gen === genRef.current) setDetail(d) })
|
||||
.catch(e => { if (gen === genRef.current) setErr(e.message) })
|
||||
}
|
||||
|
||||
if (err) return <RdmErrorState raw={err} />
|
||||
if (!shell) return <div className="empty-note">加载中…</div>
|
||||
if (shell.items.length === 0) return <EmptyState icon="folder">暂无{kind === 'notes' ? '骨架节点' : '文档'}</EmptyState>
|
||||
.catch(e => { if (gen === genRef.current) setDetailError(e.message) })
|
||||
}, [pid])
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree">
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell.items.map(item => (
|
||||
<div key={item.id}>
|
||||
<button className={`rdm-audit-tree-node${selected?.id === item.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(item)}>
|
||||
{item.kind === 'doc' && <span className="rdm-audit-tree-caret">{children[item.id] ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</button>
|
||||
{item.kind === 'doc' && children[item.id] && (
|
||||
<div className="rdm-audit-tree-children">
|
||||
{children[item.id].length === 0 && <div className="empty-note">无章节</div>}
|
||||
{children[item.id].map(sub => (
|
||||
<button key={sub.id} className={`rdm-audit-tree-node${selected?.id === sub.id ? ' sel' : ''}`}
|
||||
onClick={() => selectNode(sub)}>
|
||||
<span className="rdm-audit-tree-kind">{sub.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{sub.title}</span>
|
||||
{sub.state && <Chip tone={sub.state === 'baselined' ? 'ok' : 'plain'}>{sub.state}</Chip>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{shell.hasMore && <button className="btn v-ghost rdm-audit-more" onClick={loadMore}>加载更多</button>}
|
||||
{shell.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
<TreeLevel pid={pid} kind={kind} parent="" selected={selected} onSelect={selectNode} />
|
||||
{selected && (
|
||||
<div className="rdm-audit-detail">
|
||||
<SectionLabel>对象详情</SectionLabel>
|
||||
{!detail ? <div className="empty-note">加载中…</div> : <ObjectDetailView detail={detail} />}
|
||||
{detailError ? <>
|
||||
<RdmErrorState raw={detailError} />
|
||||
<button className="btn v-ghost" onClick={() => selectNode(selected)}>重试详情</button>
|
||||
</> : detail ? <ObjectDetailView detail={detail} /> : <div className="empty-note">加载中…</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TreeLevelProps { pid: string; kind: 'notes' | 'docs'; parent: string; selected: TreeItem | null; onSelect: (item: TreeItem) => void }
|
||||
|
||||
function TreeLevel({ pid, kind, parent, selected, onSelect }: TreeLevelProps) {
|
||||
const [shell, setShell] = useState<ListShell<TreeItem> | null>(null)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const genRef = useRef(0)
|
||||
const cursorRef = useRef('')
|
||||
const load = useCallback((cursor = '') => {
|
||||
const gen = ++genRef.current
|
||||
cursorRef.current = cursor
|
||||
setLoading(true); setError('')
|
||||
api.rdmCommand<ListShell<TreeItem>>('rdm.inspect.tree', { projectId: pid, kind, parent, cursor })
|
||||
.then(next => {
|
||||
if (gen !== genRef.current) return
|
||||
setShell(prev => cursor && prev ? { ...next, items: [...prev.items, ...next.items] } : next)
|
||||
})
|
||||
.catch(e => { if (gen === genRef.current) setError(e.message) })
|
||||
.finally(() => { if (gen === genRef.current) setLoading(false) })
|
||||
}, [pid, kind, parent])
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
return () => { genRef.current++ }
|
||||
}, [load])
|
||||
|
||||
return (
|
||||
<div className="rdm-audit-tree-list">
|
||||
{shell?.items.map(item => <TreeNode key={`${item.kind}:${item.id}`} item={item}
|
||||
pid={pid} kind={kind} selected={selected} onSelect={onSelect} />)}
|
||||
{!shell && loading && <div className="empty-note">加载中…</div>}
|
||||
{shell?.items.length === 0 && <div className="empty-note">{parent ? kind === 'notes' ? '无子骨架或挂载切片' : '无章节' : kind === 'notes' ? '暂无骨架节点或切片' : '暂无文档'}</div>}
|
||||
{error && <>
|
||||
<RdmErrorState raw={error} />
|
||||
<button className="btn v-ghost" disabled={loading} onClick={() => load(cursorRef.current)}>重试列表</button>
|
||||
</>}
|
||||
{shell?.hasMore && !error && <button className="btn v-ghost rdm-audit-more" disabled={loading}
|
||||
onClick={() => load(shell.nextCursor)}>加载更多</button>}
|
||||
{shell?.truncated && <div className="rdm-audit-truncated">已截断(深度或数量上限)</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeNode({ item, pid, kind, selected, onSelect }: Omit<TreeLevelProps, 'parent'> & { item: TreeItem }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const branch = item.kind === 'doc' || item.kind === 'spine'
|
||||
const className = `rdm-audit-tree-node${selected?.kind === item.kind && selected.id === item.id ? ' sel' : ''}`
|
||||
const label = <>
|
||||
{branch && <span className="rdm-audit-tree-caret">{open ? '▾' : '▸'}</span>}
|
||||
<span className="rdm-audit-tree-kind">{item.kind}</span>
|
||||
<span className="rdm-audit-tree-title">{item.title}</span>
|
||||
{item.state && <Chip tone={item.state === 'baselined' ? 'ok' : 'plain'}>{item.state}</Chip>}
|
||||
</>
|
||||
if (!branch) return <button className={className} onClick={() => onSelect(item)}>{label}</button>
|
||||
return <Collapsible open={open} trigger={label} triggerClassName={className}
|
||||
bodyClassName="rdm-audit-tree-children" onToggle={next => {
|
||||
setOpen(next)
|
||||
if (item.kind === 'spine') onSelect(item)
|
||||
}}>
|
||||
<TreeLevel pid={pid} kind={kind} parent={item.id} selected={selected} onSelect={onSelect} />
|
||||
</Collapsible>
|
||||
}
|
||||
|
||||
// ---- 关联追溯 ----
|
||||
|
||||
function TraceTab({ pid }: { pid: string }) {
|
||||
@@ -470,6 +475,11 @@ function ObjectDetailView({ detail }: { detail: ObjectDetail }) {
|
||||
<div className="rdm-audit-detail-body">
|
||||
<div><strong>{detail.title || detail.id}</strong></div>
|
||||
<div>{detail.kind}:{detail.id}{detail.ver ? `@${detail.ver}` : ''}</div>
|
||||
{detail.kind === 'spine' && <>
|
||||
<div>父骨架:{detail.parentId || '根节点'}</div>
|
||||
<div>子骨架 {detail.childCount ?? 0} · 挂载切片 {detail.sliceCount ?? 0}</div>
|
||||
</>}
|
||||
{!!detail.mounts?.length && <div>挂载骨架:{detail.mounts.join('、')}</div>}
|
||||
{detail.state && <div>状态:<Chip tone={detail.state === 'baselined' ? 'ok' : 'plain'}>{detail.state}</Chip></div>}
|
||||
{detail.labels && <div>标签:{detail.labels.stage}{detail.labels.topics?.length ? ` / ${detail.labels.topics.join(', ')}` : ''}</div>}
|
||||
{detail.versions && detail.versions.length > 0 && (
|
||||
|
||||
@@ -21,7 +21,7 @@ function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClo
|
||||
if (x + el.offsetWidth > window.innerWidth - 8) x = window.innerWidth - el.offsetWidth - 8
|
||||
if (y + el.offsetHeight > window.innerHeight - 8) y = r.top - el.offsetHeight - 6
|
||||
el.style.left = `${Math.max(8, x)}px`
|
||||
el.style.top = `${y}px`
|
||||
el.style.top = `${Math.max(8, Math.min(y, window.innerHeight - el.offsetHeight - 8))}px`
|
||||
const h = (e: MouseEvent) => { if (!el.contains(e.target as Node)) onClose() }
|
||||
window.addEventListener('mousedown', h)
|
||||
return () => window.removeEventListener('mousedown', h)
|
||||
@@ -36,7 +36,16 @@ function MenuItem({ onClick, danger, icon, children }: { onClick: () => void; da
|
||||
</button>
|
||||
}
|
||||
|
||||
export function TaskSidebar() {
|
||||
interface TaskSidebarProps {
|
||||
onCollapse?: () => void
|
||||
onConversationOpen?: () => void
|
||||
onFilesOpen?: () => void
|
||||
onWorkOpen?: () => void
|
||||
}
|
||||
|
||||
export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
||||
onFilesOpen = store.openFiles, onWorkOpen = store.focusPending,
|
||||
}: TaskSidebarProps = {}) {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
@@ -58,16 +67,10 @@ export function TaskSidebar() {
|
||||
const [creatingProject, setCreatingProject] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
// ⌘N/Ctrl+N 新建任务(P6:创建入口唯一 + 快捷键)。
|
||||
useEffect(() => {
|
||||
const h = (e: KeyboardEvent) => {
|
||||
const k = e.key.toLowerCase()
|
||||
if ((e.metaKey || e.ctrlKey) && k === 'n') { e.preventDefault(); store.createTask() }
|
||||
if ((e.metaKey || e.ctrlKey) && k === 'b') { e.preventDefault(); store.toggleLeft() }
|
||||
}
|
||||
window.addEventListener('keydown', h, true)
|
||||
return () => window.removeEventListener('keydown', h, true)
|
||||
}, [])
|
||||
const createConversation = async (projectId?: string) => {
|
||||
await store.createTask(projectId)
|
||||
onConversationOpen?.()
|
||||
}
|
||||
|
||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable })
|
||||
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
|
||||
@@ -97,16 +100,21 @@ export function TaskSidebar() {
|
||||
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||||
if (next) store.openTask(next.id)
|
||||
else store.openProject(pid)
|
||||
onConversationOpen?.()
|
||||
}
|
||||
|
||||
const taskRow = (t: Conversation) => (
|
||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||
onClick={() => store.openTask(t.id)}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
||||
<Icon name="message" size={14} />
|
||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
|
||||
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
|
||||
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||
</button>
|
||||
{rowActions(t)}
|
||||
<button className="tsb-row-menu" aria-label={`「${t.title || '未命名对话'}」更多操作`} title="更多对话操作"
|
||||
onClick={e => setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget })}><Icon name="more" size={14} /></button>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -121,22 +129,21 @@ export function TaskSidebar() {
|
||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
|
||||
</div>
|
||||
|
||||
{/* 单一主创建入口(P6):新建任务 + 次级菜单(新建项目/指定项目新建) */}
|
||||
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
|
||||
<div className="tsb-actions">
|
||||
<div className="tsb-newtask">
|
||||
<button className="tsb-newtask-main" title="在当前项目新建任务(⌘N)" onClick={() => store.createTask()}>
|
||||
<Icon name="plus" size={15} />新建任务{currentProject ? <span className="tsb-newtask-proj">{currentProject.name}</span> : null}
|
||||
<button className="tsb-newtask-main" title="在当前项目新建对话(⌘N)" onClick={() => createConversation()}>
|
||||
<Icon name="plus" size={15} />新建对话
|
||||
</button>
|
||||
<button className="tsb-newtask-caret" title="更多创建选项"
|
||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'new' ? null : { kind: 'new', anchor: el }) }}><Icon name="chevron-down" size={14} /></button>
|
||||
</div>
|
||||
<div className="tsb-search">
|
||||
<Icon name="search" size={14} />
|
||||
<input placeholder="搜索任务…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -172,14 +179,14 @@ export function TaskSidebar() {
|
||||
)}
|
||||
|
||||
{rest.length === 0 && pinned.length === 0 && (
|
||||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||||
<div className="tsb-empty">从「新建对话」开始,描述你想完成的工作。</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
|
||||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||||
<div className="tsb-bottom">
|
||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载" onClick={onFilesOpen}>
|
||||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||||
<span className="tsb-tool-name">文件</span>
|
||||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||||
@@ -191,7 +198,6 @@ export function TaskSidebar() {
|
||||
<kbd>{entry.shortcut}</kbd>
|
||||
</button>
|
||||
))}
|
||||
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
|
||||
{currentProject && (
|
||||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||||
@@ -211,7 +217,7 @@ export function TaskSidebar() {
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
|
||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={onWorkOpen}><Icon name="inbox" size={15} />{pendingBadge}</button>}
|
||||
</div>
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
@@ -232,11 +238,11 @@ export function TaskSidebar() {
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||
<MenuItem key={p.id} onClick={() => { store.createTask(p.id); setMenu(null) }}>在「{p.name}」新建任务</MenuItem>
|
||||
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
|
||||
@@ -262,7 +268,7 @@ export function TaskSidebar() {
|
||||
</div>
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
|
||||
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
|
||||
<MenuItem icon="inbox" onClick={() => { onWorkOpen(); setMenu(null) }}>待处理事项{pendingBadge > 0 ? `(${pendingBadge})` : ''}</MenuItem>
|
||||
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
|
||||
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
|
||||
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
|
||||
|
||||
+43
-11
@@ -1,20 +1,52 @@
|
||||
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
|
||||
// 阶段徽章随切换器;本栏不再重复。
|
||||
// Project identity lives in context; the top bar keeps work/files reachable when panes close.
|
||||
import { BrandMark, Icon, pendingCount } from '@ui'
|
||||
import { store, useStore } from '../store'
|
||||
import type { WorkbenchPane } from '../workbenchLayout'
|
||||
|
||||
export function TopBar() {
|
||||
interface TopBarProps {
|
||||
compact?: boolean
|
||||
activePane?: WorkbenchPane
|
||||
showReturn?: boolean
|
||||
onContext?: () => void
|
||||
onConversation?: () => void
|
||||
onWork?: () => void
|
||||
onFiles?: () => void
|
||||
}
|
||||
|
||||
export function TopBar({ compact = false, activePane = 'conversation', showReturn = false,
|
||||
onContext = store.toggleLeft, onConversation,
|
||||
onWork = () => { store.openInbox(); store.setUI({ inboxFilter: 'all', previewExpanded: false }) }, onFiles = store.openFiles,
|
||||
}: TopBarProps = {}) {
|
||||
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
|
||||
const ui = useStore(s => s.ui)
|
||||
const returnVisible = showReturn || compact && activePane !== 'conversation'
|
||||
return (
|
||||
<header className="topbar">
|
||||
<BrandMark size={22} />
|
||||
<span className="topbar-name">Agent-System</span>
|
||||
<header className={`topbar companion-topbar${compact ? ' compact' : ''}`}>
|
||||
<div className="topbar-identity"><BrandMark size={22} /><span className="topbar-name">工作助手</span></div>
|
||||
<span className="topbar-spacer" />
|
||||
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开工作入口` : '工作入口'}
|
||||
onClick={() => store.focusPending()}>
|
||||
<Icon name="inbox" size={16} />
|
||||
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
|
||||
</button>
|
||||
{returnVisible && <button className="shell-return" aria-label="返回对话" onClick={onConversation}>
|
||||
<Icon name="chevron-right" className="shell-back-icon" size={14} /><span>返回对话</span>
|
||||
</button>}
|
||||
<nav className="shell-navigation" aria-label="工作台导航">
|
||||
{compact && <>
|
||||
<button className="shell-nav-button" aria-label="打开上下文" aria-pressed={activePane === 'context'} onClick={onContext}>
|
||||
<Icon name="panel-left" size={15} /><span>上下文</span>
|
||||
</button>
|
||||
<button className="shell-nav-button" aria-label="打开对话" aria-pressed={activePane === 'conversation'} onClick={onConversation}>
|
||||
<Icon name="message" size={15} /><span>对话</span>
|
||||
</button>
|
||||
</>}
|
||||
<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>
|
||||
{pending > 0 && <span className="shell-pending" data-testid="topbar-pending">{pending > 99 ? '99+' : pending}</span>}
|
||||
</button>
|
||||
<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>
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const docBridge: DocBridge = {
|
||||
|
||||
const inboxCallbacks = {
|
||||
onAction: (c: ActivityCard, id: string) => store.cardAction(c, id),
|
||||
actionState: (c: ActivityCard) => store.getState().cardActionStates[c.id],
|
||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
||||
@@ -52,6 +53,8 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
// Subscribe to shared feedback even when no card object has changed yet.
|
||||
useStore(s => s.cardActionStates)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 文档职责 · 整文审签」。全部数据经 api.rdmCommand(命令总线),载荷构造与能力
|
||||
// 门控集中在 src/rdmManagement.ts;分区可见性由 AdminZone/ProjectSettingsModal
|
||||
// 按 store 的 rdm*Available 门控(命令面不齐不渲染,普通基座零暴露)。样式只复用
|
||||
// 既有 ad-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
// 既有 admin-*/lg-*/settings-* 类(design-guard 棘轮:不新增裸色值/字号)。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { useStore } from '../store'
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
memberAddPayload, memberRemovePayload,
|
||||
assignDocDutiesPayload, docSubmitPayload, docApprovePayload, docRejectPayload,
|
||||
docFinalPayload, docStatusPayload, permCheckPayload, describeSource,
|
||||
rolesListPlaceholder, describeRoleListError,
|
||||
} from '../rdmManagement'
|
||||
|
||||
// ---- 系统管理:RDM 角色权限(仅管理员 + rdmRolesAvailable) ----
|
||||
@@ -37,103 +38,106 @@ export function RdmRolesPanel() {
|
||||
const [editRole, setEditRole] = useState('')
|
||||
const [editAction, setEditAction] = useState<string>(ROLE_ACTIONS[0])
|
||||
const [editDef, setEditDef] = useState(false)
|
||||
const [editCap, setEditCap] = useState(true)
|
||||
const [editCap, setEditCap] = useState(false)
|
||||
// 绑定
|
||||
const [assignOwner, setAssignOwner] = useState('')
|
||||
const sel = roles?.find(role => role.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
const out = await api.rdmCommand<{ roles: RoleView[] }>('rdm.role.list')
|
||||
setRoles(out.roles ?? [])
|
||||
} catch (e) { setErr(String(e)) }
|
||||
setErr(''); setRoles(out.roles ?? [])
|
||||
} catch (e) { setRoles(null); setErr(describeRoleListError(String(e))) }
|
||||
}
|
||||
useEffect(() => { refresh() }, [])
|
||||
|
||||
useEffect(() => {
|
||||
setEditDef(editCurrent?.[0] ?? false)
|
||||
setEditCap(editCurrent?.[1] ?? false)
|
||||
}, [roles, editRole, editAction])
|
||||
|
||||
const run = async (label: string, fn: () => Promise<unknown>) => {
|
||||
setErr(''); setNotice(''); setBusy(true)
|
||||
try { await fn(); setNotice(label); await refresh() }
|
||||
catch (e) { setErr(String(e)) } finally { setBusy(false) }
|
||||
catch (e) { setErr(describeRoleListError(String(e))) } finally { setBusy(false) }
|
||||
}
|
||||
|
||||
if (!me?.isAdmin) return <EmptyState icon="shield">仅平台管理员可管理专业角色</EmptyState>
|
||||
|
||||
const sel = roles?.find(r => r.roleId === editRole)
|
||||
const editCurrent = sel?.actions?.[editAction]
|
||||
// 占位行单一出口:加载中仅在无错误时成立(拒绝后永远转圈是误导——见
|
||||
// rolesListPlaceholder 注释)。
|
||||
const placeholder = rolesListPlaceholder(roles, err)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input" placeholder="新角色名(如:编制工程师)" value={newName}
|
||||
onChange={e => setNewName(e.target.value)} />
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !newName.trim()}
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !newName.trim()}
|
||||
onClick={() => run(`已创建角色 ${newName.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.role.create', roleCreatePayload(newName.trim()))
|
||||
setNewName('')
|
||||
})}>创建角色</button>
|
||||
</div>
|
||||
<p className="ad-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
<p className="admin-hint">新角色动作未配置=全部拒绝(fail-closed),创建后经下方编辑器逐动作配置默认与上限。</p>
|
||||
</div>
|
||||
|
||||
<table className="ad-table">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>角色</th><th>状态</th><th>版本</th><th>动作配置</th><th>绑定账号</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(roles ?? []).map(r => (
|
||||
<tr key={r.roleId}>
|
||||
<td><span className="ad-mono">{r.name}</span><br /><span className="ad-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="ad-chip ad-chip-warn">已停用</span> : <span className="ad-chip">生效</span>}</td>
|
||||
<td className="ad-dim">v{r.version}</td>
|
||||
<td><span className="admin-mono">{r.name}</span><br /><span className="admin-dim">{r.roleId}</span></td>
|
||||
<td>{r.disabled ? <span className="admin-chip admin-chip-warn">已停用</span> : <span className="admin-chip">生效</span>}</td>
|
||||
<td className="admin-dim">v{r.version}</td>
|
||||
<td>{Object.keys(r.actions ?? {}).length} 项</td>
|
||||
<td className="ad-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
<td className="admin-mono">{(r.accounts ?? []).join('、') || '—'}</td>
|
||||
<td><button className="admin-mini" disabled={busy}
|
||||
onClick={() => run(r.disabled ? `已启用 ${r.name}` : `已停用 ${r.name}(绑定账号回落内置默认)`,
|
||||
() => api.rdmCommand('rdm.role.disable', roleDisablePayload(r.roleId, !r.disabled)))}>
|
||||
{r.disabled ? '启用' : '停用'}</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无专业角色</td></tr>}
|
||||
{roles === null && <tr><td colSpan={6} className="ad-dim">加载中…</td></tr>}
|
||||
{roles !== null && roles.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无专业角色</td></tr>}
|
||||
{placeholder && <tr><td colSpan={6} className="admin-dim">{placeholder}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>动作配置(默认 ∩ 上限)</h4>
|
||||
<div className="ad-row">
|
||||
<div className="admin-row">
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<select className="lg-input" value={editAction}
|
||||
onChange={e => {
|
||||
setEditAction(e.target.value)
|
||||
const cur = sel?.actions?.[e.target.value]
|
||||
setEditDef(!!cur?.[0]); setEditCap(cur ? cur[1] : true)
|
||||
}}>
|
||||
<select className="lg-input" value={editAction} onChange={e => setEditAction(e.target.value)}>
|
||||
{ROLE_ACTIONS.map(a => <option key={a} value={a}>{a}</option>)}
|
||||
</select>
|
||||
<label className="ad-check"><input type="checkbox" checked={editDef}
|
||||
<label className="admin-check"><input type="checkbox" checked={editDef}
|
||||
onChange={e => setEditDef(e.target.checked)} />默认允许</label>
|
||||
<label className="ad-check"><input type="checkbox" checked={editCap}
|
||||
<label className="admin-check"><input type="checkbox" checked={editCap}
|
||||
onChange={e => setEditCap(e.target.checked)} />上限允许</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !editRole}
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !editRole}
|
||||
onClick={() => run(`已配置 ${editRole} · ${editAction}`,
|
||||
() => api.rdmCommand('rdm.role.action', roleActionPayload(editRole, editAction, editDef, editCap)))}>保存动作</button>
|
||||
</div>
|
||||
{sel && editCurrent && <p className="ad-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
{sel && editCurrent && <p className="admin-hint">当前值:默认={String(editCurrent[0])},上限={String(editCurrent[1])}</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>绑定账号(每账号一个生效角色)</h4>
|
||||
<div className="ad-row">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input lg-mono" placeholder="账号(owner)" value={assignOwner}
|
||||
onChange={e => setAssignOwner(e.target.value)} />
|
||||
<select className="lg-input" value={editRole} onChange={e => setEditRole(e.target.value)}>
|
||||
<option value="">选择角色…</option>
|
||||
{(roles ?? []).filter(r => !r.disabled).map(r => <option key={r.roleId} value={r.roleId}>{r.name}</option>)}
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !assignOwner.trim() || !editRole}
|
||||
onClick={() => run(`已把 ${assignOwner.trim()} 绑定到 ${editRole}`,
|
||||
() => api.rdmCommand('rdm.role.assign', roleAssignPayload(assignOwner.trim(), editRole)))}>绑定</button>
|
||||
</div>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -167,41 +171,41 @@ export function RdmMembersSection({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input lg-mono" placeholder="成员账号(owner)" value={member}
|
||||
onChange={e => setMember(e.target.value)} />
|
||||
<select className="lg-input" value={role} onChange={e => setRole(e.target.value as 'member' | 'owner')}>
|
||||
<option value="member">member(成员)</option>
|
||||
<option value="owner">owner(负责人)</option>
|
||||
</select>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !member.trim()}
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !member.trim()}
|
||||
onClick={() => run(`已添加 ${member.trim()}`, async () => {
|
||||
await api.rdmCommand('rdm.project.member.add', memberAddPayload(projectId, member.trim(), role))
|
||||
setMember('')
|
||||
})}>添加成员</button>
|
||||
</div>
|
||||
<p className="ad-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
<p className="admin-hint">加入后成员即可见本项目;移除即时生效。最后一位负责人不可移除/降级。</p>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>账号</th><th>项目角色</th><th>添加人</th><th>添加时间</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(rows ?? []).map(m => (
|
||||
<tr key={m.owner}>
|
||||
<td className="ad-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="ad-chip ad-chip-admin">OWNER</span> : <span className="ad-chip">MEMBER</span>}</td>
|
||||
<td className="ad-dim">{m.addedBy || '—'}</td>
|
||||
<td className="ad-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="ad-mini" disabled={busy}
|
||||
<td className="admin-mono">{m.owner}</td>
|
||||
<td>{m.role === 'owner' ? <span className="admin-chip admin-chip-admin">OWNER</span> : <span className="admin-chip">MEMBER</span>}</td>
|
||||
<td className="admin-dim">{m.addedBy || '—'}</td>
|
||||
<td className="admin-dim">{m.addedAt ? new Date(m.addedAt).toLocaleString() : '—'}</td>
|
||||
<td><button className="admin-mini" disabled={busy}
|
||||
onClick={() => run(`已移除 ${m.owner}`,
|
||||
() => api.rdmCommand('rdm.project.member.remove', memberRemovePayload(projectId, m.owner)))}>移除</button></td>
|
||||
</tr>
|
||||
))}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="ad-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="ad-dim">加载中…</td></tr>}
|
||||
{rows !== null && rows.length === 0 && <tr><td colSpan={5} className="admin-dim">暂无成员</td></tr>}
|
||||
{rows === null && <tr><td colSpan={5} className="admin-dim">加载中…</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -248,8 +252,8 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !docId}
|
||||
@@ -260,28 +264,28 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
{sub === null && <EmptyState icon="inbox">该文档尚无整文提交记录</EmptyState>}
|
||||
{sub && (
|
||||
<div className="settings-group">
|
||||
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="ad-chip ad-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="ad-chip ad-chip-admin">已终批</span> : <span className="ad-chip">已驳回</span>}</h4>
|
||||
<div className="setting-row"><label>三权</label><span>编制 <b className="ad-mono">{sub.duties.author}</b> · 审核 <b className="ad-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="ad-mono">{sub.duties.approver}</b></span></div>
|
||||
<h4>第 {sub.round} 轮 · {sub.state === 'submitted' ? <span className="admin-chip admin-chip-warn">审签在途(全文锁定)</span> : sub.state === 'final' ? <span className="admin-chip admin-chip-admin">已终批</span> : <span className="admin-chip">已驳回</span>}</h4>
|
||||
<div className="setting-row"><label>三权</label><span>编制 <b className="admin-mono">{sub.duties.author}</b> · 审核 <b className="admin-mono">{(sub.duties.reviewers ?? []).join('、')}</b> · 批准 <b className="admin-mono">{sub.duties.approver}</b></span></div>
|
||||
<div className="setting-row"><label>审核进度</label><span>{Object.entries(sub.reviews ?? {}).map(([r, ok]) => `${r}:${ok ? '✓' : '…'}`).join(' ') || '—'}</span></div>
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="ad-mono ad-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="ad-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
<div className="setting-row"><label>冻结摘要</label><span className="admin-mono admin-dim">{(sub.digest ?? '').slice(0, 16)}…</span></div>
|
||||
<div className="admin-row" style={{ marginTop: 8 }}>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('整文终批完成(各章节已基线)',
|
||||
() => cmd('rdm.review.doc.final', docFinalPayload(projectId, docId)))}>终批(批准人)</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已通过整文审核',
|
||||
() => cmd('rdm.review.doc.approve', docApprovePayload(projectId, docId, '通过')))}>审核通过</button>
|
||||
<button className="ad-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
<button className="admin-mini" disabled={busy || sub.state !== 'submitted'}
|
||||
onClick={() => run('已驳回整文(解锁回编写态)',
|
||||
() => cmd('rdm.review.doc.reject', docRejectPayload(projectId, docId, '退回修改')))}>驳回</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sub === undefined && docId && <p className="ad-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
{sub === undefined && docId && <p className="admin-hint">输入文档 ID 后查看整文状态。</p>}
|
||||
|
||||
<h4 className="settings-group-title" style={{ marginTop: 18 }}>分配文档三权(整文)</h4>
|
||||
<div className="ad-row">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input lg-mono" placeholder="文档 ID(docId)" value={docId}
|
||||
onChange={e => setDocId(e.target.value)} />
|
||||
<input className="lg-input lg-mono" placeholder="编制人(author)" value={author}
|
||||
@@ -291,18 +295,18 @@ export function RdmDutiesSection({ projectId }: { projectId: string }) {
|
||||
<input className="lg-input lg-mono" placeholder="批准人(approver)" value={approver}
|
||||
onChange={e => setApprover(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<button className="lg-submit ad-create-btn"
|
||||
<div className="admin-row">
|
||||
<button className="lg-submit admin-create-btn"
|
||||
disabled={busy || !docId || !author.trim() || !approver.trim() || parseReviewers(reviewers).length === 0}
|
||||
onClick={() => run('已分配文档三权', () => cmd('rdm.review.assign.doc',
|
||||
assignDocDutiesPayload(projectId, docId, author.trim(), parseReviewers(reviewers), approver.trim())))}>分配三权</button>
|
||||
<button className="ad-mini" disabled={busy || !docId}
|
||||
<button className="admin-mini" disabled={busy || !docId}
|
||||
onClick={() => run('整文已提交审核(全文锁定)',
|
||||
() => cmd('rdm.review.doc.submit', docSubmitPayload(projectId, docId)))}>整文提交(编制人)</button>
|
||||
</div>
|
||||
<p className="ad-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
<p className="admin-hint">三权异人:编制≠批准,审核异于两者;提交即冻结三权与全文版本,审签在途全文锁定。</p>
|
||||
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
@@ -346,39 +350,39 @@ export function RdmPermMatrixSection({ projectId }: { projectId: string }) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<select className="lg-input" value={member} onChange={e => { setMember(e.target.value); query(e.target.value) }}>
|
||||
<option value="">选择成员(默认自省)…</option>
|
||||
{(members ?? []).map(m => <option key={m.owner} value={m.owner}>{m.owner}</option>)}
|
||||
</select>
|
||||
<Btn variant="ghost" icon="eye" disabled={busy || !member} onClick={() => query(member)}>查看矩阵</Btn>
|
||||
</div>
|
||||
<p className="ad-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
|
||||
<p className="admin-hint">普通成员可查看自己的有效权限;查看他人矩阵须项目管理权。矩阵与实际判定同源(服务端解释,非前端推算)。</p>
|
||||
</div>
|
||||
{matrix && (
|
||||
<>
|
||||
<div className="ad-row">
|
||||
<span>成员 <b className="ad-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
<div className="admin-row">
|
||||
<span>成员 <b className="admin-mono">{matrix.member}</b> · 项目关系 <b>{matrix.role}</b>
|
||||
{matrix.profRole && <> · 专业角色 <b>{matrix.profRole.name}</b>({matrix.profRole.roleId})</>}
|
||||
</span>
|
||||
</div>
|
||||
<table className="ad-table">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>动作</th><th>生效</th><th>来源</th><th>原因(拒绝时)</th></tr></thead>
|
||||
<tbody>
|
||||
{(matrix.actions ?? []).map(a => (
|
||||
<tr key={a.action}>
|
||||
<td className="ad-mono">{a.action}</td>
|
||||
<td>{a.allowed ? <span className="ad-chip ad-chip-admin">允许</span> : <span className="ad-chip ad-chip-warn">拒绝</span>}</td>
|
||||
<td className="ad-dim">{describeSource(a.source)}</td>
|
||||
<td className="ad-dim">{a.reason || '—'}</td>
|
||||
<td className="admin-mono">{a.action}</td>
|
||||
<td>{a.allowed ? <span className="admin-chip admin-chip-admin">允许</span> : <span className="admin-chip admin-chip-warn">拒绝</span>}</td>
|
||||
<td className="admin-dim">{describeSource(a.source)}</td>
|
||||
<td className="admin-dim">{a.reason || '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)}
|
||||
{!matrix && !err && members !== null && <p className="ad-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{!matrix && !err && members !== null && <p className="admin-hint">选择成员后展示 12 项动作的有效权限与第一拒绝原因。</p>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -58,47 +58,47 @@ export function UsersPanel() {
|
||||
return (
|
||||
<div>
|
||||
{me?.isAdmin && (
|
||||
<div className="ad-create">
|
||||
<div className="ad-row">
|
||||
<div className="admin-create">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input" placeholder="用户名(登录名)" value={owner} onChange={e => setOwner(e.target.value)} />
|
||||
<input className="lg-input" placeholder="显示名(可空)" value={name} onChange={e => setName(e.target.value)} />
|
||||
</div>
|
||||
<div className="ad-row">
|
||||
<div className="admin-row">
|
||||
<input className="lg-input lg-mono" placeholder="初始密码(≥8 位)" value={pw} onChange={e => setPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="ad-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setPw(randomPassword())}>随机</button>
|
||||
<label className="admin-check"><input type="checkbox" checked={asAdmin} onChange={e => setAsAdmin(e.target.checked)} />管理员</label>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !owner.trim() || !pw} onClick={create}>创建账号</button>
|
||||
</div>
|
||||
<p className="ad-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||
<p className="admin-hint">创建后该用户首次登录必须修改此初始密码。</p>
|
||||
</div>
|
||||
)}
|
||||
{notice && <div className="ad-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{notice && <div className="admin-notice"><Icon name="check" size={14} />{notice}</div>}
|
||||
{err && <div className="lg-err"><Icon name="alert" size={14} />{err}</div>}
|
||||
{resetFor ? (
|
||||
<div className="ad-reset">
|
||||
<div className="admin-reset">
|
||||
<span>重置 <b>{resetFor}</b> 的密码:</span>
|
||||
<input className="lg-input lg-mono" placeholder="新密码(≥8 位)" value={resetPw} onChange={e => setResetPw(e.target.value)} />
|
||||
<button type="button" className="ad-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||
<button className="lg-submit ad-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||
<button type="button" className="ad-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||
<button type="button" className="admin-mini" title="生成随机密码" onClick={() => setResetPw(randomPassword())}>随机</button>
|
||||
<button className="lg-submit admin-create-btn" disabled={busy || !resetPw} onClick={reset}>确认重置</button>
|
||||
<button type="button" className="admin-mini" onClick={() => { setResetFor(null); setResetPw('') }}>取消</button>
|
||||
</div>
|
||||
) : (
|
||||
<table className="ad-table">
|
||||
<table className="admin-table">
|
||||
<thead><tr><th>用户名</th><th>显示名</th><th>角色</th><th>状态</th><th>密码设置于</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{(users ?? []).map(u => (
|
||||
<tr key={u.owner}>
|
||||
<td className="ad-user-cell"><span className="ad-mono">{u.owner}</span></td>
|
||||
<td className="admin-user-cell"><span className="admin-mono">{u.owner}</span></td>
|
||||
<td>{u.name || '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="ad-chip ad-chip-admin">ADMIN</span> : <span className="ad-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="ad-chip ad-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="ad-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{u.isAdmin ? <span className="admin-chip admin-chip-admin">ADMIN</span> : <span className="admin-chip">USER</span>}</td>
|
||||
<td>{u.mustChangePassword ? <span className="admin-chip admin-chip-warn">待改密</span> : u.hasPassword ? '正常' : '未设密码'}</td>
|
||||
<td className="admin-dim">{u.passwordSetAt ? new Date(u.passwordSetAt).toLocaleString() : '—'}</td>
|
||||
<td>{me?.isAdmin && (
|
||||
<button className="ad-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||
<button className="admin-mini" onClick={() => { setResetFor(u.owner); setResetPw('') }}>重置密码</button>
|
||||
)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="ad-dim">暂无账号</td></tr>}
|
||||
{users !== null && users.length === 0 && <tr><td colSpan={6} className="admin-dim">暂无账号</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
+4
-4
@@ -29,7 +29,7 @@ export type LiveItem =
|
||||
| { kind: 'check'; passed: boolean; reason: string }
|
||||
| { kind: 'final' }
|
||||
|
||||
export type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
||||
type LiveTool = Extract<LiveItem, { kind: 'tool' }>
|
||||
|
||||
const OUTPUT_TAIL = 4096
|
||||
|
||||
@@ -246,9 +246,9 @@ export function livePhaseLabel(live: LiveState, now = Date.now()): string {
|
||||
return '回复中'
|
||||
}
|
||||
|
||||
// showProcessInline:运行中时间线始终展开。run 结束后随「自动折叠过程」偏好收成一行。
|
||||
export function showProcessInline(live: LiveState | null, autoCollapse: boolean): boolean {
|
||||
return live != null || !autoCollapse
|
||||
// 默认阅读层只显示阶段;运行中也把技术记录留给用户主动展开。
|
||||
export function showProcessInline(_live: LiveState | null, autoCollapse: boolean): boolean {
|
||||
return !autoCollapse
|
||||
}
|
||||
|
||||
// hasSettledReply:助手回复落库后,这个 run 的实时气泡才该收起。
|
||||
|
||||
+11
-15
@@ -1,5 +1,5 @@
|
||||
// 消息切分:过程时间线 = 从开头到最后一个 process 部件(中间夹着的内容部件留在时间线里);
|
||||
// 最终段 = 它之后的正文与交付卡片。渲染顺序仍是落库顺序,只在这一刀上折叠。
|
||||
// 消息切分:技术记录留在过程时间线,正文、错误与文件入口始终直接可读。
|
||||
// 两区各自保留落库顺序,后续工具或终轮检查不再把解说藏入折叠区。
|
||||
import type { Part } from './contracts'
|
||||
|
||||
export interface TimelineSplit {
|
||||
@@ -7,22 +7,18 @@ export interface TimelineSplit {
|
||||
final: Part[]
|
||||
}
|
||||
|
||||
/** 最后一个 process 部件之前(含自身)是工作时间线,其余是最终回复。
|
||||
* promoteLastText:回复区没有正文时,把时间线里最后一段解说提上来。失败或被截断的 run 用它保住可见回复。 */
|
||||
export function splitTimeline(parts: Part[], promoteLastText = false): TimelineSplit {
|
||||
/** 最后一个 process 部件之前的技术记录留在时间线,其余为可见内容。
|
||||
* 保留旧 promoteLastText 参数以兼容调用方;解说现在始终可见,不再依赖终态提升。 */
|
||||
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 }
|
||||
const timeline = parts.slice(0, last + 1)
|
||||
const final = parts.slice(last + 1)
|
||||
if (!promoteLastText || final.some(p => p.type === 'text' && (p.text ?? '').trim())) return { timeline, final }
|
||||
let textAt = -1
|
||||
for (let i = timeline.length - 1; i >= 0; i--) {
|
||||
if (timeline[i].type === 'text' && (timeline[i].text ?? '').trim()) { textAt = i; break }
|
||||
}
|
||||
if (textAt < 0) return { timeline, final }
|
||||
const lifted = { ...timeline[textAt], process: undefined }
|
||||
return { timeline: timeline.filter((_, i) => i !== textAt), final: [lifted, ...final] }
|
||||
// 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)]
|
||||
return { timeline, final }
|
||||
}
|
||||
|
||||
/** 同一轮里同名工件只留最新一张,updates 是被折叠掉的次数。 */
|
||||
|
||||
@@ -105,6 +105,23 @@ export function describeSource(source: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
/** 角色清单表格占位行文案:加载中仅在无错误时成立——rdm.role.list 被拒后
|
||||
* 停留「加载中…」是 2026-10-13 8788 锁死事故的误导性表象(表格永远在转圈)。 */
|
||||
export function rolesListPlaceholder(roles: unknown[] | null, err: string): string {
|
||||
if (roles !== null) return roles.length === 0 ? '暂无专业角色' : ''
|
||||
return err ? '角色清单未加载(见下方提示)' : '加载中…'
|
||||
}
|
||||
|
||||
/** rdm.role.list 错误文案映射:域管理员权限拒绝给人话 + 恢复通道;其余错误
|
||||
* 原样保留(服务端 reason 变化不至于空白)。台账单管理员制见 RDM G1-4/U1。 */
|
||||
export function describeRoleListError(raw: string): string {
|
||||
if (raw.includes('不是系统管理员')) {
|
||||
return '当前账号不是 RDM 域管理员(台账单管理员制),无法读取角色清单;'
|
||||
+ '请联系现任管理员经 rdm.perm.transfer 移交,或由部署侧受信通道恢复'
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
/** 审阅人 CSV 解析:去空白、去空项、保序去重(表单输入 → reviewers 数组)。 */
|
||||
export function parseReviewers(csv: string): string[] {
|
||||
const seen = new Set<string>()
|
||||
|
||||
+339
-52
@@ -109,6 +109,8 @@ interface AppState {
|
||||
projectSurfaces: SurfaceMeta[]
|
||||
cards: ActivityCard[]
|
||||
projectCards: ActivityCard[]
|
||||
/** 同一操作的双入口反馈;未知结果必须先对账,不重新发送副作用。 */
|
||||
cardActionStates: Record<string, 'submitting' | 'unknown'>
|
||||
inboxScope: 'conv' | 'project'
|
||||
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
|
||||
runtimeMock: boolean
|
||||
@@ -134,18 +136,20 @@ interface AppState {
|
||||
outboxDraining: boolean
|
||||
previewTabs: Record<string, PreviewTab[]>
|
||||
previewActive: Record<string, string | null>
|
||||
/** Ephemeral explicit opening intent; restoring saved metadata must not advance it. */
|
||||
previewOpenIntent: number
|
||||
}
|
||||
|
||||
const DEFAULT_UI: UIState = {
|
||||
leftWidth: 260,
|
||||
leftCollapsed: false,
|
||||
rightWidth: 360,
|
||||
rightCollapsed: false,
|
||||
rightCollapsed: true,
|
||||
rightTab: 'work',
|
||||
filesView: 'list', workspaceSurfacePin: null,
|
||||
inboxFilter: 'all',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
theme: 'light',
|
||||
fontSize: 14,
|
||||
autoCollapseProcess: true,
|
||||
settingsOpen: false,
|
||||
@@ -185,14 +189,14 @@ let state: AppState = {
|
||||
contextObs: {},
|
||||
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||
serverWorkspaces: [],
|
||||
rdmAuditAvailable: false,
|
||||
rdmRolesAvailable: false, rdmMembersAvailable: false, rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false,
|
||||
adminSections: [],
|
||||
ui: loadUI(), connected: false, posting: false, error: null, toast: null,
|
||||
outbox: [], outboxDraining: false,
|
||||
previewTabs: {}, previewActive: {},
|
||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0,
|
||||
}
|
||||
|
||||
const listeners = new Set<() => void>()
|
||||
@@ -274,6 +278,116 @@ function flashLocate(root: string, attr: string, id: string) {
|
||||
let sse: { close: () => void } | null = null
|
||||
let projectSse: { close: () => void } | null = null
|
||||
let projectSeq = 0
|
||||
let conversationEpoch = 0
|
||||
let streamSeq = 0
|
||||
let detailRequest = 0
|
||||
let detailSeq = 0
|
||||
let detailVersion = 0
|
||||
type DetailObservation = { version: number; seq: number; terminalSeq?: number; terminalVersion?: number }
|
||||
let runObservations: Record<string, DetailObservation> = {}
|
||||
let messageObservations: Record<string, DetailObservation> = {}
|
||||
let hitlObservations: Record<string, DetailObservation> = {}
|
||||
let cardsRequest = 0
|
||||
let projectCardsRequest = 0
|
||||
let cardVersion = 0
|
||||
let projectCardVersion = 0
|
||||
let cardVersions: Record<string, number> = {}
|
||||
let projectCardVersions: Record<string, number> = {}
|
||||
let cardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
|
||||
let projectCardPatches: Record<string, Pick<ActivityCard, 'status' | 'tone'>> = {}
|
||||
const actionInflight = new Set<string>()
|
||||
const actionFeedbackOwners: Record<string, symbol> = {}
|
||||
|
||||
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||
const version = ++detailVersion
|
||||
observations[id] = { ...observations[id], version, seq,
|
||||
...(terminal ? { terminalSeq: seq, terminalVersion: version } : {}) }
|
||||
}
|
||||
|
||||
function detailRowIsNewer(observations: Record<string, DetailObservation>, id: string, started: number, seq: number) {
|
||||
const observation = observations[id]
|
||||
return observation && (observation.version > started || observation.seq > seq)
|
||||
}
|
||||
|
||||
function terminalObservationIsNewer(id: string, started: number, seq: number) {
|
||||
const observation = runObservations[id]
|
||||
return (observation?.terminalVersion ?? 0) > started || (observation?.terminalSeq ?? 0) > seq
|
||||
}
|
||||
|
||||
function mergeDetailRows<T extends { id: string }>(snapshot: T[], current: T[], observations: Record<string, DetailObservation>, started: number, seq: number) {
|
||||
const fresh = current.filter(row => detailRowIsNewer(observations, row.id, started, seq))
|
||||
const byId = new Map(fresh.map(row => [row.id, row]))
|
||||
const snapshotIds = new Set(snapshot.map(row => row.id))
|
||||
return [...snapshot.map(row => byId.get(row.id) ?? row), ...fresh.filter(row => !snapshotIds.has(row.id))]
|
||||
}
|
||||
|
||||
function scheduleTerminalReconcile() {
|
||||
const convId = state.currentConvId
|
||||
const epoch = conversationEpoch
|
||||
setTimeout(() => {
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
void store.reconcile().then(() => {
|
||||
if (state.currentConvId === convId && conversationEpoch === epoch) return store.flushOutbox()
|
||||
})
|
||||
}, 250)
|
||||
}
|
||||
|
||||
// Snapshot queries may overlap events. Only rows changed after the request began
|
||||
// override that snapshot; a newer request/context invalidates the old query entirely.
|
||||
function mergeCardSnapshot(snapshot: ActivityCard[], current: ActivityCard[], versions: Record<string, number>, started: number,
|
||||
patches: Record<string, Pick<ActivityCard, 'status' | 'tone'>>) {
|
||||
const fresh = current.filter(c => (versions[c.id] ?? 0) > started)
|
||||
const freshIds = new Set(fresh.map(c => c.id))
|
||||
return [...fresh, ...snapshot.filter(c => !freshIds.has(c.id)).map(c =>
|
||||
(versions[c.id] ?? 0) > started && patches[c.id] ? { ...c, ...patches[c.id], tone: patches[c.id].tone ?? c.tone } : c)]
|
||||
}
|
||||
|
||||
function hitlSource(card: ActivityCard): string | undefined {
|
||||
for (const node of card.body ?? []) {
|
||||
const value = node.type === 'kv' ? node.kv?.find(([key]) => key === '来源')?.[1] : undefined
|
||||
if (value?.startsWith('hitl:')) return value.slice(5)
|
||||
}
|
||||
}
|
||||
|
||||
function actionFeedback(key: string, value?: 'submitting' | 'unknown', ids: string[] = [], owner?: symbol) {
|
||||
let states: AppState['cardActionStates'] | undefined
|
||||
for (const id of [key, ...ids]) {
|
||||
if (owner && actionFeedbackOwners[id] !== owner) continue
|
||||
const current = states ?? state.cardActionStates
|
||||
if (value ? current[id] === value : !(id in current)) continue
|
||||
states ??= { ...state.cardActionStates }
|
||||
if (value) states[id] = value
|
||||
else { delete states[id]; delete actionFeedbackOwners[id] }
|
||||
}
|
||||
if (states) set({ cardActionStates: states })
|
||||
}
|
||||
|
||||
function startActionFeedback(key: string, ids: string[] = []) {
|
||||
const owner = Symbol(key)
|
||||
for (const id of [key, ...ids]) actionFeedbackOwners[id] = owner
|
||||
actionFeedback(key, 'submitting', ids, owner)
|
||||
return owner
|
||||
}
|
||||
|
||||
function settleCardFeedback(cards: ActivityCard[]) {
|
||||
let states: AppState['cardActionStates'] | undefined
|
||||
for (const card of cards) {
|
||||
if (card.status === 'action_required') continue
|
||||
const source = hitlSource(card)
|
||||
for (const id of [card.id, ...(source ? [`hitl:${source}`] : [])]) {
|
||||
if (!(id in (states ?? state.cardActionStates))) continue
|
||||
states ??= { ...state.cardActionStates }
|
||||
delete states[id]
|
||||
delete actionFeedbackOwners[id]
|
||||
}
|
||||
}
|
||||
if (states) set({ cardActionStates: states })
|
||||
}
|
||||
|
||||
const liveEventTypes = new Set<StreamEvent['type']>([
|
||||
'message.delta', 'thinking.delta', 'tool.call.delta', 'tool.call', 'tool.output',
|
||||
'tool.result', 'run.phase', 'run.check',
|
||||
])
|
||||
|
||||
export function useStore<T>(selector: (s: AppState) => T): T {
|
||||
return useSyncExternalStore(
|
||||
@@ -326,6 +440,19 @@ export const store = {
|
||||
},
|
||||
|
||||
async openProject(projectId: string) {
|
||||
if (state.currentProjectId !== projectId) {
|
||||
projectSse?.close(); projectSse = null
|
||||
sse?.close(); sse = null
|
||||
projectSeq = 0
|
||||
projectCardsRequest++
|
||||
projectCardVersion = 0; projectCardVersions = {}; projectCardPatches = {}
|
||||
conversationEpoch++; streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||
cards: [], projectCards: [], plan: null, todos: null, surfaces: [], projectSurfaces: [],
|
||||
artifacts: [], outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false, filesView: 'list' })
|
||||
}
|
||||
set({ currentProjectId: projectId })
|
||||
if (store.getState().inboxScope === 'project') {
|
||||
store.loadProjectCards()
|
||||
@@ -394,7 +521,11 @@ export const store = {
|
||||
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
const epoch = ++conversationEpoch
|
||||
streamSeq = 0; cardVersion = 0; cardVersions = {}; cardPatches = {}
|
||||
detailSeq = 0; detailVersion = 0; runObservations = {}; messageObservations = {}; hitlObservations = {}
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], surfaces: [], projectSurfaces: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
setUI({ previewExpanded: false })
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
@@ -406,9 +537,9 @@ export const store = {
|
||||
}
|
||||
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
|
||||
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
|
||||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
|
||||
api.surfaceList(convId).then(async list => {
|
||||
if (state.currentConvId !== convId) return
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
const metas = list ?? []
|
||||
// 刷新/重进恢复:有页面且尚无页面标签时补开最新一张的标签(不强切
|
||||
// 右栏——只在实时事件到达时切换,避免每次进会话劫持视图)。标签只用 Meta。
|
||||
@@ -426,52 +557,84 @@ export const store = {
|
||||
const fetched = await Promise.all(metas.map(m =>
|
||||
api.surfaceGet(String(m.surfaceId)).catch(() => null),
|
||||
))
|
||||
if (state.currentConvId !== convId) return
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
const hydrated = fetched.filter((s): s is SurfaceSnapshot => surfaceKey(s) != null)
|
||||
// 慢水合不得覆盖并发 surface.updated 已写入的较新 revision。
|
||||
set({ surfaces: mergeSurfacesByRevision(state.surfaces, hydrated) })
|
||||
}).catch(() => {})
|
||||
const resumeSeq = await store.reconcile()
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
|
||||
// 终态对账:以 Query 的权威消息树/运行树/游标为准收敛。
|
||||
// 返回值是实时流应从这个 seq 之后重放;有进行中的 run 时它早于 lastSeq。
|
||||
async reconcile(): Promise<number | undefined> {
|
||||
if (!state.currentConvId) return
|
||||
const convId = state.currentConvId
|
||||
const epoch = conversationEpoch
|
||||
const request = ++detailRequest
|
||||
const started = detailVersion
|
||||
if (!convId) return
|
||||
try {
|
||||
const d = await api.conversationDetail(state.currentConvId)
|
||||
const liveRunActive = state.live && !isTerminal(d.runs.find(r => r.id === state.live!.runId)?.status)
|
||||
const d = await api.conversationDetail(convId)
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest || d.lastSeq < detailSeq) return
|
||||
// A background frame may overtake this snapshot without changing its
|
||||
// messages/runs. Preserve only newer observations of the same object.
|
||||
const messages = mergeDetailRows(d.messages, state.messages, messageObservations, started, d.lastSeq)
|
||||
const snapshotRuns = new Map(d.runs.map(run => [run.id, run]))
|
||||
const runs = mergeDetailRows(d.runs, state.runs, runObservations, started, d.lastSeq).map(run => {
|
||||
const snapshot = snapshotRuns.get(run.id)
|
||||
const newerTerminal = terminalObservationIsNewer(run.id, started, d.lastSeq)
|
||||
return snapshot && isTerminal(snapshot.status) && !isTerminal(run.status) && !newerTerminal ? snapshot : run
|
||||
})
|
||||
const activeHitl = mergeDetailRows(d.activeHitl, state.activeHitl, hitlObservations, started, d.lastSeq)
|
||||
.filter(card => !detailRowIsNewer(hitlObservations, card.id, started, d.lastSeq) || state.activeHitl.some(c => c.id === card.id))
|
||||
const live = state.live
|
||||
const liveRunActive = live && (!isTerminal(runs.find(r => r.id === live.runId)?.status) ||
|
||||
(terminalObservationIsNewer(live.runId, started, d.lastSeq) && !messages.some(m => m.role === 'assistant' && m.runId === live.runId)))
|
||||
const resumeSeq = d.resumeSeq ?? d.lastSeq
|
||||
detailSeq = d.lastSeq
|
||||
set({
|
||||
messages: d.messages,
|
||||
runs: d.runs,
|
||||
activeHitl: d.activeHitl,
|
||||
messages,
|
||||
runs,
|
||||
activeHitl,
|
||||
lastSeq: Math.max(state.lastSeq, d.lastSeq),
|
||||
live: liveRunActive ? state.live : null,
|
||||
connected: true,
|
||||
})
|
||||
const [plan, todos] = await Promise.all([api.getPlan(d.conversation.id), api.getTodos(d.conversation.id)])
|
||||
set({ plan, todos })
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== detailRequest) return
|
||||
if (state.lastSeq === d.lastSeq) set({ plan, todos })
|
||||
store.refreshCards(d.conversation.id)
|
||||
store.refreshTasks()
|
||||
return resumeSeq
|
||||
} catch (e) {
|
||||
set({ error: String(e) })
|
||||
if (state.currentConvId === convId && conversationEpoch === epoch) set({ error: String(e) })
|
||||
}
|
||||
},
|
||||
|
||||
connectLive(since?: number) {
|
||||
if (!state.currentConvId) return
|
||||
const convId = state.currentConvId
|
||||
const epoch = conversationEpoch
|
||||
streamSeq = Math.max(streamSeq, since ?? state.lastSeq)
|
||||
sse?.close()
|
||||
sse = connectEvents(convId, since ?? state.lastSeq, {
|
||||
onOpen: () => set({ connected: true }),
|
||||
onError: () => set({ connected: false }),
|
||||
onEvent: e => store.applyEvent(e),
|
||||
onOpen: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: true }) },
|
||||
onError: () => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ connected: false }) },
|
||||
onEvent: e => { if (state.currentConvId === convId && conversationEpoch === epoch) store.applyEvent(e) },
|
||||
})
|
||||
},
|
||||
|
||||
applyEvent(e: StreamEvent) {
|
||||
if (!state.currentConvId || e.conversationId !== state.currentConvId || !Number.isFinite(e.seq) || e.seq <= streamSeq) return
|
||||
streamSeq = e.seq
|
||||
const previousSeq = state.lastSeq
|
||||
// Running-turn replay reconstructs transient output, but cannot regress the
|
||||
// persistent card/plan/status snapshot already returned by the detail query.
|
||||
if (e.seq <= previousSeq && !liveEventTypes.has(e.type)) return
|
||||
set({ lastSeq: Math.max(previousSeq, e.seq) })
|
||||
if (e.runId && liveEventTypes.has(e.type)) observeDetail(runObservations, e.runId, e.seq)
|
||||
const now = eventTime(e)
|
||||
const project = (mutate: (l: LiveState) => void) => touchLive(e.runId ?? '', mutate, now)
|
||||
switch (e.type) {
|
||||
@@ -519,26 +682,33 @@ export const store = {
|
||||
break
|
||||
}
|
||||
case 'hitl.request': {
|
||||
const card = e.payload as HITLCard
|
||||
if (card?.id) {
|
||||
observeDetail(hitlObservations, card.id, e.seq)
|
||||
set({ activeHitl: [...state.activeHitl.filter(c => c.id !== card.id), card] })
|
||||
}
|
||||
store.reconcile()
|
||||
break
|
||||
}
|
||||
case 'hitl.resolved': {
|
||||
set({ activeHitl: state.activeHitl.filter(c => c.id !== (e.payload as { cardId: string }).cardId) })
|
||||
const cardId = (e.payload as { cardId: string }).cardId
|
||||
observeDetail(hitlObservations, cardId, e.seq)
|
||||
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
|
||||
break
|
||||
}
|
||||
case 'agent.spawn': {
|
||||
const p = e.payload as { agentRun: string; name: string; task: string }
|
||||
set({ live: project(l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: `已派生 ${p.name}:${p.task}` } }) })
|
||||
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: `已派生 ${p.name}` } } })
|
||||
break
|
||||
}
|
||||
case 'agent.progress': {
|
||||
const p = e.payload as { agentRun: string; note?: string }
|
||||
set({ live: project(l => { if (p.note) l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.note! } }) })
|
||||
if (state.live && p.note) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.note } } })
|
||||
break
|
||||
}
|
||||
case 'agent.result': {
|
||||
const p = e.payload as { agentRun: string; summary?: string }
|
||||
set({ live: project(l => { l.agentNotes = { ...l.agentNotes, [p.agentRun]: p.summary ?? '完成' } }) })
|
||||
if (state.live) set({ live: { ...state.live, agentNotes: { ...state.live.agentNotes, [p.agentRun]: p.summary ?? '任务已结束' } } })
|
||||
store.reconcile()
|
||||
break
|
||||
}
|
||||
@@ -563,22 +733,19 @@ export const store = {
|
||||
if (!existing || p.revision > existing.revision) {
|
||||
surfaces.push(p)
|
||||
set({ surfaces })
|
||||
// 新页面到达:没有页面标签或当前正在看页面 → 开标签并把右栏切到
|
||||
// 文件预览(与 requestPreview 同语义,用户能立刻看到);否则不抢占
|
||||
// 已打开的对象,但留 toast 痕迹(KTD「新结果不抢占」)。
|
||||
// Only update the title of the page already being read. New background
|
||||
// pages stay in the work list and never open a pane or change selection.
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const tabs = state.previewTabs[conv] ?? []
|
||||
const activeId = state.previewActive[conv] ?? null
|
||||
const hasSurfaceTab = tabs.some(t => t.id.startsWith('surface:'))
|
||||
if (!hasSurfaceTab || (activeId ?? '').startsWith('surface:')) {
|
||||
if (activeId === 'surface:' + id) {
|
||||
const opened = openPreviewTab(tabs, { id: 'surface:' + id, name: p.spec?.title || '页面' })
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
} else {
|
||||
store.toast(`页面已更新:${p.spec?.title || id}`)
|
||||
store.toast(`新成果可查看:${p.spec?.title || '任务页面'}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -591,14 +758,20 @@ export const store = {
|
||||
}
|
||||
case 'card.status': {
|
||||
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||||
cardVersions[p.cardId] = ++cardVersion
|
||||
cardPatches[p.cardId] = { status: p.status, tone: p.tone }
|
||||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)
|
||||
set({ cards })
|
||||
settleCardFeedback(cards)
|
||||
break
|
||||
}
|
||||
case 'card.resolved': {
|
||||
const p = e.payload as { cardId: string }
|
||||
cardVersions[p.cardId] = ++cardVersion
|
||||
cardPatches[p.cardId] = { status: 'resolved' }
|
||||
const cards = state.cards.map(c => c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)
|
||||
set({ cards })
|
||||
settleCardFeedback(cards)
|
||||
break
|
||||
}
|
||||
case 'artifact.created':
|
||||
@@ -614,17 +787,19 @@ export const store = {
|
||||
break
|
||||
}
|
||||
case 'run.completed': {
|
||||
if (e.runId) observeDetail(runObservations, e.runId, e.seq, true)
|
||||
const p = e.payload as { usage?: unknown; context?: ContextObs }
|
||||
if (p?.context && e.runId) {
|
||||
set({ contextObs: { ...state.contextObs, [e.runId]: p.context } })
|
||||
}
|
||||
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
|
||||
scheduleTerminalReconcile()
|
||||
break
|
||||
}
|
||||
case 'run.failed':
|
||||
case 'error': {
|
||||
if (e.runId && e.type === 'run.failed') observeDetail(runObservations, e.runId, e.seq, true)
|
||||
// 终态:短暂延迟后对账(等待消息树落库),折叠过程输出,再发送排队消息。
|
||||
setTimeout(() => { void store.reconcile().then(() => store.flushOutbox()) }, 250)
|
||||
scheduleTerminalReconcile()
|
||||
break
|
||||
}
|
||||
default: {
|
||||
@@ -635,11 +810,15 @@ export const store = {
|
||||
},
|
||||
|
||||
async sendMessage(text: string, attachmentIds: string[], requestId?: string): Promise<'ok' | 'busy'> {
|
||||
if (!state.currentConvId) return 'ok'
|
||||
const convId = state.currentConvId
|
||||
const epoch = conversationEpoch
|
||||
const sameContext = () => state.currentConvId === convId && conversationEpoch === epoch
|
||||
if (!convId) return 'ok'
|
||||
const id = requestId ?? newQueuedMessage(text, attachmentIds, []).id
|
||||
set({ posting: true })
|
||||
try {
|
||||
const out = await api.sendMessage(state.currentConvId, text, attachmentIds, id)
|
||||
const out = await api.sendMessage(convId, text, attachmentIds, id)
|
||||
if (!sameContext()) return 'ok'
|
||||
if (out.command) {
|
||||
store.toast(out.reply ?? `命令 ${out.command} 已执行`)
|
||||
if (out.conversation) await store.openConversation(out.conversation.id)
|
||||
@@ -649,6 +828,8 @@ export const store = {
|
||||
}
|
||||
if (out.run) {
|
||||
const started = out.run
|
||||
observeDetail(runObservations, started.id)
|
||||
observeDetail(messageObservations, out.message.id)
|
||||
// 立刻把 run 合进列表,停止/排队不必等终态对账。已有同 id 记录保持不动。
|
||||
const runs = mergeStartedRun(state.runs, started)
|
||||
const messages = state.messages.some(m => m.id === out.message.id)
|
||||
@@ -669,6 +850,7 @@ export const store = {
|
||||
}
|
||||
return 'ok'
|
||||
} catch (e) {
|
||||
if (!sameContext()) return 'ok'
|
||||
if (isConversationBusy(e)) {
|
||||
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [] }) })
|
||||
return 'busy'
|
||||
@@ -677,7 +859,7 @@ export const store = {
|
||||
await store.reconcile()
|
||||
return 'ok'
|
||||
} finally {
|
||||
set({ posting: false })
|
||||
if (sameContext()) set({ posting: false })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -736,45 +918,124 @@ export const store = {
|
||||
},
|
||||
|
||||
async resolveHITL(cardId: string, resp: { choiceId?: string; freeText?: string }) {
|
||||
const key = `hitl:${cardId}`
|
||||
const related = [...state.cards, ...state.projectCards].filter(c => hitlSource(c) === cardId).map(c => c.id)
|
||||
if (actionInflight.has(key) || state.cardActionStates[key]) return
|
||||
const convId = state.currentConvId
|
||||
const epoch = conversationEpoch
|
||||
actionInflight.add(key)
|
||||
const feedbackOwner = startActionFeedback(key, related)
|
||||
try {
|
||||
const out = await api.resolveHITL(cardId, resp)
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
if (!out.resolved) {
|
||||
actionFeedback(key, 'unknown', related, feedbackOwner)
|
||||
await store.reconcile()
|
||||
return
|
||||
}
|
||||
set({ activeHitl: state.activeHitl.filter(c => c.id !== cardId) })
|
||||
// Resolution is not necessarily execution: fetch the actual confirmation
|
||||
// outcome (including cancellation) instead of stamping every choice resolved.
|
||||
if (convId) await store.refreshCards(convId)
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
actionFeedback(key, undefined, [], feedbackOwner)
|
||||
for (const id of related) {
|
||||
const current = [...state.cards, ...state.projectCards].find(c => c.id === id)
|
||||
actionFeedback(id, current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
|
||||
}
|
||||
if (out.pivoted && out.pivotText) {
|
||||
await store.sendMessage(out.pivotText, [])
|
||||
}
|
||||
} catch (e) {
|
||||
store.toast(`HITL 解析失败:${e}`)
|
||||
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
|
||||
actionFeedback(key, rejected ? undefined : 'unknown', related, feedbackOwner)
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
|
||||
if (rejected) {
|
||||
store.toast('本次确认未被接受,请查看当前事项后再处理')
|
||||
} else {
|
||||
store.toast('确认结果暂时未知,正在核实处理状态')
|
||||
await store.reconcile()
|
||||
}
|
||||
} finally {
|
||||
actionInflight.delete(key)
|
||||
}
|
||||
},
|
||||
|
||||
upsertCard(card: ActivityCard) {
|
||||
if (card.conversationId && card.conversationId !== state.currentConvId) return
|
||||
cardVersions[card.id] = ++cardVersion
|
||||
delete cardPatches[card.id]
|
||||
const i = state.cards.findIndex(c => c.id === card.id)
|
||||
const cards = i >= 0 ? state.cards.map(c => c.id === card.id ? card : c) : [card, ...state.cards]
|
||||
set({ cards })
|
||||
settleCardFeedback([card])
|
||||
},
|
||||
|
||||
async refreshCards(convId: string) {
|
||||
try { set({ cards: await api.listCards(convId) }) } catch { /* 会话竞态,静默 */ }
|
||||
const epoch = conversationEpoch
|
||||
const request = ++cardsRequest
|
||||
const started = cardVersion
|
||||
try {
|
||||
const snapshot = await api.listCards(convId)
|
||||
if (state.currentConvId !== convId || conversationEpoch !== epoch || request !== cardsRequest) return
|
||||
const cards = mergeCardSnapshot(snapshot, state.cards, cardVersions, started, cardPatches)
|
||||
set({ cards })
|
||||
settleCardFeedback(cards)
|
||||
} catch { /* Keep the last observed snapshot while disconnected. */ }
|
||||
},
|
||||
|
||||
// 卡片动作:确认投影卡(来源 hitl:)路由回 HITL 解析(恢复挂起 run,服务端同步销项);
|
||||
// 其余卡走卡片动作 API,并把服务端持久化的回执消息追加进本地时间线(id 一致,对账不重复)。
|
||||
async cardAction(card: ActivityCard, actionId: string, freeText?: string) {
|
||||
const hitlRef = card.body?.find(n => n.type === 'kv')?.kv?.find(([k]) => k === '来源')?.[1]
|
||||
if (hitlRef?.startsWith('hitl:')) {
|
||||
await store.resolveHITL(hitlRef.slice(5), actionId ? { choiceId: actionId } : { freeText })
|
||||
const source = hitlSource(card)
|
||||
if (source) {
|
||||
await store.resolveHITL(source, actionId ? { choiceId: actionId } : { freeText })
|
||||
return
|
||||
}
|
||||
if (actionInflight.has(card.id) || state.cardActionStates[card.id]) return
|
||||
const convId = state.currentConvId
|
||||
const pid = state.currentProjectId
|
||||
const epoch = conversationEpoch
|
||||
const startedVersion = card.conversationId === state.currentConvId ? cardVersion : projectCardVersion
|
||||
const sameContext = () => state.currentProjectId === pid && (!card.conversationId || (state.currentConvId === convId && conversationEpoch === epoch))
|
||||
actionInflight.add(card.id)
|
||||
const feedbackOwner = startActionFeedback(card.id)
|
||||
try {
|
||||
const out = await api.cardAction(card.id, actionId, freeText)
|
||||
store.upsertCard(out.card)
|
||||
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
|
||||
if (!sameContext()) return
|
||||
const newer = (card.conversationId === state.currentConvId ? cardVersions[card.id] : projectCardVersions[card.id]) > startedVersion
|
||||
if (!newer && card.conversationId === state.currentConvId) store.upsertCard(out.card)
|
||||
else if (!newer) {
|
||||
projectCardVersions[out.card.id] = ++projectCardVersion
|
||||
const cards = state.projectCards
|
||||
set({ projectCards: cards.some(c => c.id === out.card.id) ? cards.map(c => c.id === out.card.id ? out.card : c) : [out.card, ...cards] })
|
||||
}
|
||||
if (out.receipt && out.receipt.conversationId === state.currentConvId && !state.messages.some(m => m.id === out.receipt?.id)) {
|
||||
observeDetail(messageObservations, out.receipt.id)
|
||||
set({ messages: [...state.messages, out.receipt as Message] })
|
||||
}
|
||||
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
|
||||
if (card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
|
||||
await store.refreshCards(card.conversationId)
|
||||
if (!newer && card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
|
||||
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
|
||||
else await store.loadProjectCards()
|
||||
}
|
||||
if (sameContext()) {
|
||||
const current = [...state.cards, ...state.projectCards].find(c => c.id === card.id)
|
||||
actionFeedback(card.id, actionId !== 'recheck' && current?.status === 'action_required' ? 'unknown' : undefined, [], feedbackOwner)
|
||||
}
|
||||
} catch (e) {
|
||||
store.toast(`操作失败:${e}`)
|
||||
const rejected = /^4\d\d:/.test(String(e instanceof Error ? e.message : e))
|
||||
actionFeedback(card.id, rejected ? undefined : 'unknown', [], feedbackOwner)
|
||||
if (!sameContext()) return
|
||||
if (rejected) {
|
||||
store.toast('本次操作未被接受,请查看当前事项后再处理')
|
||||
} else {
|
||||
store.toast('操作结果暂时未知,正在核实处理状态')
|
||||
if (card.conversationId === state.currentConvId) await store.refreshCards(card.conversationId)
|
||||
else await store.loadProjectCards()
|
||||
}
|
||||
} finally {
|
||||
actionInflight.delete(card.id)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -789,6 +1050,7 @@ export const store = {
|
||||
async refreshArtifacts(projectId: string) {
|
||||
try {
|
||||
const artifacts = await api.listArtifacts(projectId)
|
||||
if (state.currentProjectId !== projectId) return
|
||||
set({ artifacts })
|
||||
} catch { /* 项目删除竞态等,静默 */ }
|
||||
},
|
||||
@@ -831,14 +1093,16 @@ export const store = {
|
||||
},
|
||||
|
||||
async uploadAttachment(file: File): Promise<string | null> {
|
||||
if (!state.currentProjectId) return null
|
||||
const pid = state.currentProjectId
|
||||
if (!pid) return null
|
||||
try {
|
||||
const up = await api.upload(state.currentProjectId, file)
|
||||
store.toast(`已上传 ${up.filename}${up.parsed ? `(解析:${up.parsed.meta.parser})` : ''}`)
|
||||
await store.refreshArtifacts(state.currentProjectId)
|
||||
const up = await api.upload(pid, file)
|
||||
if (state.currentProjectId !== pid) return null
|
||||
store.toast(`已收到材料:${up.filename}${up.parsed ? ' · 已读取内容' : ''}`)
|
||||
await store.refreshArtifacts(pid)
|
||||
return up.artifactId
|
||||
} catch (e) {
|
||||
store.toast(`上传失败:${e}`)
|
||||
if (state.currentProjectId === pid) store.toast('材料未能上传,请检查文件后重新选择')
|
||||
return null
|
||||
}
|
||||
},
|
||||
@@ -852,7 +1116,15 @@ export const store = {
|
||||
async loadProjectCards() {
|
||||
const pid = store.getState().currentProjectId
|
||||
if (!pid) return
|
||||
try { set({ projectCards: await api.listProjectCards(pid) }) } catch { /* 静默:切项目竞态 */ }
|
||||
const request = ++projectCardsRequest
|
||||
const started = projectCardVersion
|
||||
try {
|
||||
const snapshot = await api.listProjectCards(pid)
|
||||
if (state.currentProjectId !== pid || request !== projectCardsRequest) return
|
||||
const projectCards = mergeCardSnapshot(snapshot, state.projectCards, projectCardVersions, started, projectCardPatches)
|
||||
set({ projectCards })
|
||||
settleCardFeedback(projectCards)
|
||||
} catch { /* Keep existing readable content while reconnecting. */ }
|
||||
},
|
||||
|
||||
setInboxScope(scope: 'conv' | 'project') {
|
||||
@@ -870,15 +1142,19 @@ export const store = {
|
||||
const pid = store.getState().currentProjectId
|
||||
if (!pid) return
|
||||
projectSse?.close()
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => store.applyProjectEvent(e) }, pid)
|
||||
projectSse = connectEvents('', projectSeq, { onEvent: e => { if (state.currentProjectId === pid) store.applyProjectEvent(e) } }, pid)
|
||||
},
|
||||
|
||||
applyProjectEvent(e: StreamEvent) {
|
||||
projectSeq = Math.max(projectSeq, e.seq)
|
||||
if (!Number.isFinite(e.seq) || e.seq <= projectSeq) return
|
||||
projectSeq = e.seq
|
||||
const upsert = (card: ActivityCard) => {
|
||||
if (card.projectId !== state.currentProjectId) return
|
||||
projectCardVersions[card.id] = ++projectCardVersion
|
||||
const list = store.getState().projectCards
|
||||
const i = list.findIndex(c => c.id === card.id)
|
||||
set({ projectCards: i >= 0 ? list.map(c => (c.id === card.id ? card : c)) : [card, ...list] })
|
||||
settleCardFeedback([card])
|
||||
}
|
||||
switch (e.type) {
|
||||
case 'card.created':
|
||||
@@ -887,14 +1163,20 @@ export const store = {
|
||||
break
|
||||
case 'card.status': {
|
||||
const p = e.payload as { cardId: string; status: ActivityCard['status']; tone?: string }
|
||||
projectCardVersions[p.cardId] = ++projectCardVersion
|
||||
projectCardPatches[p.cardId] = { status: p.status, tone: p.tone }
|
||||
const list = store.getState().projectCards
|
||||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: p.status, tone: p.tone ?? c.tone } : c)) })
|
||||
settleCardFeedback(state.projectCards)
|
||||
break
|
||||
}
|
||||
case 'card.resolved': {
|
||||
const p = e.payload as { cardId: string }
|
||||
projectCardVersions[p.cardId] = ++projectCardVersion
|
||||
projectCardPatches[p.cardId] = { status: 'resolved' }
|
||||
const list = store.getState().projectCards
|
||||
set({ projectCards: list.map(c => (c.id === p.cardId ? { ...c, status: 'resolved' as const } : c)) })
|
||||
settleCardFeedback(state.projectCards)
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -939,6 +1221,7 @@ export const store = {
|
||||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||
locateMessage(messageId?: string) {
|
||||
if (!messageId) return
|
||||
setUI({ rightCollapsed: true, previewExpanded: false })
|
||||
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
@@ -952,6 +1235,7 @@ export const store = {
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
previewOpenIntent: state.previewOpenIntent + 1,
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
@@ -963,6 +1247,7 @@ export const store = {
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
previewOpenIntent: state.previewOpenIntent + 1,
|
||||
})
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
@@ -1016,6 +1301,8 @@ export const store = {
|
||||
}
|
||||
|
||||
function touchLive(_runId: string, mutate: (l: LiveState) => void, now = Date.now()): LiveState | null {
|
||||
if (state.runs.find(r => r.id === _runId)?.kind === 'subagent') return state.live
|
||||
if (state.live && state.live.runId !== _runId && !isTerminal(state.runs.find(r => r.id === state.live?.runId)?.status)) return state.live
|
||||
const base = state.live ?? { runId: _runId, phase: 'thinking', items: [], agentNotes: {}, startedAt: now }
|
||||
const next: LiveState = { ...base, agentNotes: { ...base.agentNotes } }
|
||||
mutate(next)
|
||||
|
||||
+24
-24
@@ -497,29 +497,29 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.az-section-head p { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-faint); }
|
||||
|
||||
/* ---- 用户管理 / 改密弹层 ---- */
|
||||
.ad-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||
.ad-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||
.ad-row { display: flex; gap: 8px; align-items: center; }
|
||||
.ad-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||
.ad-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.ad-notice svg { flex: none; margin-top: 1px; }
|
||||
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
.admin-body { padding: 16px 20px 20px; display: flex; flex-direction: column; gap: 12px; max-height: 70vh; overflow-y: auto; }
|
||||
.admin-create { border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 14px; display: flex; flex-direction: column; gap: 8px; background: var(--bg-panel); }
|
||||
.admin-row { display: flex; gap: 8px; align-items: center; }
|
||||
.admin-row .lg-input { flex: 1; min-width: 0; height: 34px; }
|
||||
.admin-check { display: flex; align-items: center; gap: 5px; font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; }
|
||||
.admin-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||
.admin-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.admin-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.admin-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.admin-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.admin-notice svg { flex: none; margin-top: 1px; }
|
||||
.admin-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.admin-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.admin-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.admin-table tr:last-child td { border-bottom: none; }
|
||||
.admin-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.admin-dim { color: var(--text-dim); }
|
||||
.admin-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.admin-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.admin-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.admin-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.admin-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.admin-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
|
||||
/* ---- 收起轨道(左栏收起态) ---- */
|
||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
|
||||
@@ -531,7 +531,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.role-mark.role-assistant { background: none; border: none; padding: 0; }
|
||||
.msg.user .msg-role { align-items: center; }
|
||||
.ad-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
.admin-user-cell { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
.artifact-chip .ac-act { border: none; background: none; }
|
||||
|
||||
|
||||
+30
-8
@@ -8,9 +8,9 @@ import type { StreamEvent, SurfaceMeta, SurfaceSnapshot } from './contracts'
|
||||
// U4/R5:surface.updated 投影的 revision 守卫——旧修订/重复事件不得覆盖新修订;
|
||||
// 服务端快照是唯一权威(KTD8 单一状态所有者)。
|
||||
|
||||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string): StreamEvent<SurfaceSnapshot> {
|
||||
function surfEvent(seq: number, surfaceId: string, revision: number, title: string, conversationId = 'c1'): StreamEvent<SurfaceSnapshot> {
|
||||
return {
|
||||
seq, conversationId: 'c1', ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||
seq, conversationId, ts: new Date().toISOString(), v: 1, type: 'surface.updated',
|
||||
payload: {
|
||||
identity: { surfaceId, owner: 'alice', pluginId: 'generative-ui', conversationId: 'c1' },
|
||||
compat: { schemaVersion: 1, catalogVersion: 'ui-v1' },
|
||||
@@ -20,6 +20,24 @@ function surfEvent(seq: number, surfaceId: string, revision: number, title: stri
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => ({
|
||||
conversation: { id, projectId: 'p1', title: id, createdAt: '', updatedAt: '' },
|
||||
messages: [], runs: [], activeHitl: [], lastSeq: 0,
|
||||
}))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(apiModule, 'connectEvents').mockReturnValue({ close: () => {} })
|
||||
await store.openConversation('c1')
|
||||
for (const tab of store.getState().previewTabs.c1 ?? []) store.closePreview(tab.id)
|
||||
store.setUI({ rightTab: 'work', rightCollapsed: true, filesView: 'list' })
|
||||
})
|
||||
afterEach(() => vi.restoreAllMocks())
|
||||
|
||||
describe('surface.updated projection', () => {
|
||||
it('新事件入库;旧修订与重复事件不覆盖', () => {
|
||||
store.applyEvent(surfEvent(1, 's1', 1, '初版'))
|
||||
@@ -33,6 +51,7 @@ describe('surface.updated projection', () => {
|
||||
})
|
||||
|
||||
it('多页面并存,互不覆盖', () => {
|
||||
store.applyEvent(surfEvent(1, 's1', 1, '页面一'))
|
||||
store.applyEvent(surfEvent(5, 's2', 1, '页面二'))
|
||||
const list = store.getState().surfaces
|
||||
expect(list.filter(x => String(x.identity.surfaceId) === 's1')).toHaveLength(1)
|
||||
@@ -42,14 +61,17 @@ describe('surface.updated projection', () => {
|
||||
|
||||
|
||||
describe('surface.updated 可见性联动', () => {
|
||||
it('自动开标签时右栏切到文件预览(syncFilesEntry 语义)', () => {
|
||||
it('keeps new background pages available without opening the file pane', () => {
|
||||
store.applyEvent(surfEvent(101, 'sv1', 1, '可见性页'))
|
||||
const ui = store.getState().ui
|
||||
expect(ui.rightTab).toBe('files')
|
||||
expect(ui.rightCollapsed).toBe(false)
|
||||
expect(ui.filesView).toBe('preview')
|
||||
expect(ui.rightTab).toBe('work')
|
||||
expect(ui.rightCollapsed).toBe(true)
|
||||
expect(ui.filesView).toBe('list')
|
||||
const tabs = store.getState().previewTabs[previewTabsKey(store.getState().currentConvId)] ?? []
|
||||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(true)
|
||||
expect(tabs.some(t => t.id === 'surface:sv1')).toBe(false)
|
||||
store.openSurface('sv1')
|
||||
expect(store.getState().ui.rightTab).toBe('files')
|
||||
expect(store.getState().previewActive.c1).toBe('surface:sv1')
|
||||
})
|
||||
|
||||
it('抑制开标签时留 toast 痕迹', () => {
|
||||
@@ -131,7 +153,7 @@ describe('saved surfaces on conversation reload', () => {
|
||||
vi.mocked(api.surfaceList).mockResolvedValue([meta('racing')])
|
||||
vi.mocked(api.surfaceGet).mockReturnValue(new Promise<SurfaceSnapshot>(r => { resolve = r }))
|
||||
await store.openConversation('race')
|
||||
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision'))
|
||||
store.applyEvent(surfEvent(301, 'racing', 2, 'live revision', 'race'))
|
||||
resolve(snapshot('racing', 1))
|
||||
await new Promise<void>(r => setTimeout(r, 0))
|
||||
expect(store.getState().surfaces).toHaveLength(1)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
export interface WorkbenchLayoutOptions {
|
||||
width: number
|
||||
contextOpen: boolean
|
||||
leftCollapsed: boolean
|
||||
rightCollapsed: boolean
|
||||
leftWidth: number
|
||||
rightWidth: number
|
||||
previewWidth: number
|
||||
rightTab: 'work' | 'files'
|
||||
filesView: 'list' | 'preview'
|
||||
previewOpen: boolean
|
||||
previewExpanded: boolean
|
||||
}
|
||||
|
||||
export type WorkbenchPane = 'context' | 'conversation' | 'work' | 'files'
|
||||
const COMPACT_WORKBENCH_WIDTH = 1024
|
||||
|
||||
function boundedWidth(value: number, fallback: number, min: number, max: number) {
|
||||
return Math.min(max, Math.max(min, Number.isFinite(value) ? value : fallback))
|
||||
}
|
||||
|
||||
// Only the rendered widths are bounded. Saved desktop preferences stay in the store.
|
||||
export function selectWorkbenchLayout(options: WorkbenchLayoutOptions) {
|
||||
const width = Number.isFinite(options.width) ? Math.max(0, options.width) : 1440
|
||||
const available = Math.max(0, width - 8)
|
||||
const compact = width < COMPACT_WORKBENCH_WIDTH
|
||||
const filesWide = options.rightTab === 'files' && options.filesView === 'preview' && options.previewOpen
|
||||
const previewExpanded = filesWide && options.previewExpanded && !options.rightCollapsed
|
||||
if (compact) {
|
||||
const pane: WorkbenchPane = options.contextOpen ? 'context' : options.rightCollapsed ? 'conversation' : options.rightTab
|
||||
return {
|
||||
compact, pane, showContext: pane === 'context', showConversation: pane === 'conversation',
|
||||
showRight: pane === 'work' || pane === 'files', contextCollapsed: false, previewExpanded,
|
||||
leftWidth: pane === 'context' ? available : 0,
|
||||
rightWidth: pane === 'work' || pane === 'files' ? available : 0,
|
||||
leftDivider: false, rightDivider: false,
|
||||
}
|
||||
}
|
||||
|
||||
const showRight = !options.rightCollapsed
|
||||
const leftDivider = !options.leftCollapsed
|
||||
const rightDivider = showRight && !previewExpanded
|
||||
const panelCount = 1 + Number(!previewExpanded) + Number(showRight)
|
||||
const gutters = (panelCount - 1) * 4 + (Number(leftDivider) + Number(rightDivider)) * 8
|
||||
const leftMin = options.leftCollapsed ? 52 : 220
|
||||
const rightMin = showRight ? filesWide ? 420 : 280 : 0
|
||||
let leftWidth = options.leftCollapsed ? 52 : boundedWidth(options.leftWidth, 260, leftMin, 420)
|
||||
let rightWidth = showRight
|
||||
? filesWide ? boundedWidth(options.previewWidth, 700, rightMin, 1100) : boundedWidth(options.rightWidth, 360, rightMin, 560)
|
||||
: 0
|
||||
if (previewExpanded) rightWidth = available - gutters - leftWidth
|
||||
else {
|
||||
const conversationMin = filesWide && showRight ? 320 : 360
|
||||
const budget = available - gutters - conversationMin
|
||||
if (leftWidth + rightWidth > budget) {
|
||||
const extraLeft = leftWidth - leftMin
|
||||
const extraRight = rightWidth - rightMin
|
||||
const ratio = Math.max(0, budget - leftMin - rightMin) / (extraLeft + extraRight || 1)
|
||||
leftWidth = leftMin + Math.floor(extraLeft * ratio)
|
||||
rightWidth = rightMin + Math.floor(extraRight * ratio)
|
||||
}
|
||||
}
|
||||
const pane: WorkbenchPane = previewExpanded ? 'files' : 'conversation'
|
||||
return {
|
||||
compact, pane,
|
||||
showContext: true, showConversation: !previewExpanded, showRight, contextCollapsed: options.leftCollapsed,
|
||||
previewExpanded, leftWidth, rightWidth, leftDivider, rightDivider,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { setPersistedOpen } from '@ui'
|
||||
import type { ActivityCard, HITLCard, Message } from '../src/contracts'
|
||||
import { Conversation } from '../src/components/Conversation'
|
||||
import { ConversationWork } from '../src/components/ConversationWork'
|
||||
import { ProcessTimeline } from '../src/components/ProcessTimeline'
|
||||
import { store, type LiveState } from '../src/store'
|
||||
|
||||
// SSR has no browser subscription snapshot. Keep the production store/actions and
|
||||
// real UI renderers; substitute only the hook boundary with a complete snapshot.
|
||||
const snapshot = vi.hoisted(() => ({ state: undefined as ReturnType<typeof store.getState> | undefined }))
|
||||
vi.mock('../src/store', async importOriginal => {
|
||||
const actual = await importOriginal<typeof import('../src/store')>()
|
||||
return { ...actual, useStore: (select: (s: ReturnType<typeof store.getState>) => unknown) => select(snapshot.state!) }
|
||||
})
|
||||
|
||||
const timestamp = '2026-10-12T10:00:00Z'
|
||||
function card(over: Partial<ActivityCard> = {}): ActivityCard {
|
||||
return {
|
||||
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1',
|
||||
kind: 'progress', title: '整理项目材料', status: 'active', tone: 'info',
|
||||
body: [{ type: 'kv', kv: [['状态', '进行中'], ['正在做', '核对会议结论'], ['材料键', 'private-material-key']] }],
|
||||
createdAt: timestamp, updatedAt: timestamp, ...over,
|
||||
}
|
||||
}
|
||||
function reply(parts: Message['parts']): Message {
|
||||
return { id: 'msg_1', conversationId: 'cnv_1', role: 'assistant', runId: 'run_1', createdAt: timestamp, parts }
|
||||
}
|
||||
function hitl(over: Partial<HITLCard> = {}): HITLCard {
|
||||
return { id: 'hitl_detail1', runId: 'run_1', conversationId: 'cnv_1', kind: 'confirm',
|
||||
question: '执行工具 run_shell?', detail: '{"command":"printf confirm-record"}',
|
||||
choices: [{ id: 'yes', label: '执行', description: '按已确认的参数调用', impact: '会访问外部资源并返回结果' },
|
||||
{ id: 'no', label: '取消', description: '跳过本次调用', impact: '不会执行本次操作' }],
|
||||
allowFreeInput: false, createdAt: timestamp, resolved: false, ...over }
|
||||
}
|
||||
function render() { return renderToStaticMarkup(createElement(Conversation)) }
|
||||
function renderWork() { return renderToStaticMarkup(createElement(ConversationWork)) }
|
||||
function completedResults(): ActivityCard[] {
|
||||
// The host lists cards newest-first; submit_card publishes markdown results.
|
||||
return [
|
||||
card({ id: 'result_latest', kind: 'result', title: '最新整理成果', status: 'done', tone: 'ok',
|
||||
updatedAt: '2026-10-12T12:00:00Z', body: [{ type: 'markdown', markdown: '最新成果的主要结论' }] }),
|
||||
card({ id: 'result_older', kind: 'result', title: '先前整理成果', status: 'done', tone: 'ok', pinned: true,
|
||||
updatedAt: '2026-10-12T10:00:00Z', body: [{ type: 'markdown', markdown: '先前成果的主要结论' }] }),
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', false)
|
||||
setPersistedOpen('conversation-work:prj_1:cnv_1', false)
|
||||
const base = store.getState()
|
||||
snapshot.state = {
|
||||
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true,
|
||||
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论项目', createdAt: timestamp, updatedAt: timestamp }],
|
||||
messages: [], cards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
|
||||
ui: { ...base.ui, rightCollapsed: true, autoCollapseProcess: true },
|
||||
cardActionStates: {},
|
||||
}
|
||||
})
|
||||
|
||||
describe('conversation work reading', () => {
|
||||
it('keeps ordinary Q&A free of an empty progress object', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '可以先确定项目目标' }])]
|
||||
const html = render()
|
||||
expect(html).toContain('可以先确定项目目标')
|
||||
expect(html).not.toContain('data-conversation-work')
|
||||
expect(html).not.toContain('data-testid="input-status"')
|
||||
})
|
||||
|
||||
it('shows the current real work with the auxiliary pane closed', () => {
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('整理项目材料')
|
||||
expect(html).toContain('核对会议结论')
|
||||
expect(html).not.toContain('private-material-key')
|
||||
})
|
||||
|
||||
it('shows the newest completed result by default with older results in closed history', () => {
|
||||
snapshot.state!.cards = completedResults()
|
||||
const html = renderWork()
|
||||
expect(html).toContain('data-card-id="result_latest"')
|
||||
expect(html).toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('data-card-id="result_older"')
|
||||
expect(html).not.toContain('先前成果的主要结论')
|
||||
expect(html).toContain('更多工作与成果 · 1 项')
|
||||
|
||||
setPersistedOpen('conversation-work:prj_1:cnv_1', true)
|
||||
expect(renderWork()).toContain('先前成果的主要结论')
|
||||
})
|
||||
|
||||
it('keeps the newest result ahead of an already resolved confirmation', () => {
|
||||
snapshot.state!.cards = [card({ id: 'confirm_resolved', kind: 'confirm', status: 'resolved', title: '已处理确认',
|
||||
updatedAt: '2026-10-12T13:00:00Z' }), ...completedResults()]
|
||||
const html = renderWork()
|
||||
expect(html).toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('data-card-id="confirm_resolved"')
|
||||
expect(html).toContain('更多工作与成果 · 2 项')
|
||||
})
|
||||
|
||||
it.each([false, true])('keeps active work ahead of completed results (pinned=%s)', pinned => {
|
||||
const [latest, older] = completedResults()
|
||||
snapshot.state!.cards = [latest, card({ id: 'work_active', title: '正在核验材料',
|
||||
updatedAt: '2026-10-12T11:00:00Z',
|
||||
body: [{ type: 'kv', kv: [['正在做', '核对活动工作的结论']] }] }), older]
|
||||
if (pinned) snapshot.state!.cards.push(card({ id: 'work_pinned', pinned: true, title: '优先核验材料',
|
||||
updatedAt: '2026-10-12T09:00:00Z',
|
||||
body: [{ type: 'kv', kv: [['正在做', '核对固定工作的结论']] }] }))
|
||||
const html = renderWork()
|
||||
expect(html).toContain(pinned ? '核对固定工作的结论' : '核对活动工作的结论')
|
||||
expect(html).not.toContain('data-card-id="result_latest"')
|
||||
expect(html).not.toContain('data-card-id="result_older"')
|
||||
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', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = completedResults()
|
||||
const html = renderWork()
|
||||
expect(html).toContain('最新整理成果 · 查看摘要')
|
||||
expect(html).not.toContain('最新成果的主要结论')
|
||||
expect(html).not.toContain('先前整理成果')
|
||||
})
|
||||
|
||||
it('keeps a compact work anchor when the same summary is visible in the right pane', () => {
|
||||
snapshot.state!.ui.rightCollapsed = false
|
||||
snapshot.state!.cards = [card()]
|
||||
const html = render()
|
||||
expect(html).toContain('整理项目材料')
|
||||
expect(html).not.toContain('核对会议结论')
|
||||
snapshot.state!.ui.rightCollapsed = true
|
||||
expect(render()).toContain('核对会议结论')
|
||||
})
|
||||
|
||||
it('keeps secondary message operations available through progressive disclosure', () => {
|
||||
snapshot.state!.messages = [reply([{ type: 'text', text: '已经整理好要点' }])]
|
||||
const html = render()
|
||||
expect(html).toContain('已经整理好要点')
|
||||
expect(html).toContain('消息操作')
|
||||
expect(html).not.toContain('排除出上下文')
|
||||
expect(html).not.toContain('重新生成')
|
||||
})
|
||||
|
||||
it('keeps failure impact and coverage limits visible without expanding history', () => {
|
||||
snapshot.state!.cards = [card({ status: 'failed', tone: 'danger', body: [
|
||||
{ type: 'kv', kv: [['状态', '失败'], ['当前发现', '已整理两份材料'], ['剩余', '评审意见未能读取,完整结论尚未生成']] },
|
||||
] })]
|
||||
const html = render()
|
||||
expect(html).toContain('已整理两份材料')
|
||||
expect(html).toContain('评审意见未能读取,完整结论尚未生成')
|
||||
})
|
||||
|
||||
it('keeps a completed result limitation visible while another task is active', () => {
|
||||
snapshot.state!.cards = [card(), card({ id: 'card_partial', status: 'done', tone: 'warn', title: '已整理部分材料',
|
||||
body: [{ type: 'kv', kv: [['当前发现', '两份材料已整理'], ['剩余', '第三份材料未能读取,请补充清晰版本']] }],
|
||||
})]
|
||||
const html = render()
|
||||
expect(html).toContain('第三份材料未能读取,请补充清晰版本')
|
||||
})
|
||||
|
||||
it('hides running command records while keeping the final reply readable', () => {
|
||||
const live: LiveState = { runId: 'run_1', phase: 'responding', startedAt: Date.now(), agentNotes: {}, items: [
|
||||
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: 'secret-command-argument', output: 'raw-command-output', state: 'running', startedAt: Date.now(), endedAt: 0 },
|
||||
{ kind: 'final' }, { kind: 'text', index: 1, text: '已找到需要补充的两项要求' },
|
||||
] }
|
||||
snapshot.state!.live = live
|
||||
const html = render()
|
||||
expect(html).toContain('已找到需要补充的两项要求')
|
||||
expect(html).not.toContain('secret-command-argument')
|
||||
expect(html).not.toContain('raw-command-output')
|
||||
expect(html).toContain('执行记录')
|
||||
})
|
||||
|
||||
it('uses a real compact stage without exposing an internal tool name', () => {
|
||||
snapshot.state!.live = { runId: 'run_1', phase: 'acting', startedAt: Date.now(), agentNotes: {}, items: [
|
||||
{ kind: 'tool', name: 'run_shell', callId: 'call_1', args: '', output: '', state: 'running', startedAt: Date.now(), endedAt: 0 },
|
||||
] }
|
||||
const html = render()
|
||||
expect(html).toContain('正在处理')
|
||||
expect(html).not.toContain('run_shell')
|
||||
})
|
||||
|
||||
it('exposes anchored required confirmation outside work history', () => {
|
||||
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('确认新增')
|
||||
})
|
||||
|
||||
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',
|
||||
title: '新增一份项目草稿', actions: [{ id: 'confirm', label: '确认新增', style: 'primary' }] })]
|
||||
expect(render()).toContain('确认新增')
|
||||
})
|
||||
|
||||
it('does not reveal internal thinking when records are deliberately opened', () => {
|
||||
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')
|
||||
})
|
||||
|
||||
it('blocks repeated HITL choices when the shared action result is unknown', () => {
|
||||
snapshot.state!.cardActionStates = { 'hitl:hitl_1': 'unknown' }
|
||||
snapshot.state!.activeHitl = [{ id: 'hitl_1', runId: 'run_1', kind: 'choice', question: '选择整理范围',
|
||||
conversationId: 'cnv_1', allowFreeInput: false, createdAt: timestamp, resolved: false,
|
||||
choices: [{ id: 'all', label: '全部材料', description: '整理三份材料', impact: '生成一份草稿' }],
|
||||
}]
|
||||
const html = render()
|
||||
expect(html).toMatch(/<button[^>]*disabled=""[^>]*>[\s\S]*?全部材料/)
|
||||
expect(html).toContain('正在核实处理结果')
|
||||
})
|
||||
|
||||
it('folds command details while retaining the confirmation and consequences', () => {
|
||||
snapshot.state!.activeHitl = [hitl()]
|
||||
const html = render()
|
||||
expect(html).not.toContain('confirm-record')
|
||||
expect(html).toContain('执行工具 run_shell?')
|
||||
expect(html).toContain('按已确认的参数调用')
|
||||
expect(html).toContain('会访问外部资源并返回结果')
|
||||
expect(html).toContain('操作详情')
|
||||
})
|
||||
|
||||
it('makes the original command detail readable after the user expands it', () => {
|
||||
snapshot.state!.activeHitl = [hitl()]
|
||||
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
|
||||
const html = render()
|
||||
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
|
||||
expect(html).toContain('confirm-record')
|
||||
})
|
||||
|
||||
it('keeps a blocking restriction visible outside the operation detail fold', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ blocked: true, blockReason: '目标文件需要先完成评审,当前不能覆盖' })]
|
||||
const html = render()
|
||||
expect(html).toContain('目标文件需要先完成评审,当前不能覆盖')
|
||||
expect(html).not.toContain('confirm-record')
|
||||
})
|
||||
|
||||
it('folds the exact workflow script suffix supplied in the question', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")', detail: undefined })]
|
||||
const html = render()
|
||||
expect(html).toContain('运行这段脚本?')
|
||||
expect(html).toContain('会访问外部资源并返回结果')
|
||||
expect(html).not.toContain('workflow-record-script')
|
||||
expect(html).toContain('操作详情')
|
||||
})
|
||||
|
||||
it('retains the full workflow script and separate detail on deliberate expansion', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ question: '运行这段脚本?\nprint("workflow-record-script")' })]
|
||||
setPersistedOpen('hitl-details:cnv_1:hitl_detail1', true)
|
||||
const html = render()
|
||||
expect(html).toContain('workflow-record-script')
|
||||
expect(html).toContain('confirm-record')
|
||||
expect(html).toMatch(/aria-expanded="true"[^>]*>[\s\S]*?操作详情/)
|
||||
})
|
||||
|
||||
it('retains multiline business consequences in an ordinary question', () => {
|
||||
snapshot.state!.activeHitl = [hitl({ question: '批准计划并继续执行?\n会覆盖旧草稿,原件保留', detail: undefined })]
|
||||
expect(render()).toContain('会覆盖旧草稿,原件保留')
|
||||
})
|
||||
})
|
||||
+7
-4
@@ -175,10 +175,13 @@ describe('livePhaseLabel', () => {
|
||||
})
|
||||
|
||||
describe('showProcessInline', () => {
|
||||
it('stays expanded for the whole live run, including the responding phase', () => {
|
||||
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(true)
|
||||
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(true)
|
||||
it('keeps live technical records folded under the default reading preference', () => {
|
||||
expect(showProcessInline(live({ phase: 'thinking' }), true)).toBe(false)
|
||||
expect(showProcessInline(live({ phase: 'acting' }), true)).toBe(false)
|
||||
expect(showProcessInline(live({ phase: 'responding' }), true)).toBe(false)
|
||||
})
|
||||
it('honors the advanced preference to expose live records', () => {
|
||||
expect(showProcessInline(live({ phase: 'acting' }), false)).toBe(true)
|
||||
})
|
||||
it('records thinking duration from the first delta to the last', () => {
|
||||
let items: LiveItem[] = applyThinkingDelta([], '先', 1000)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { splitTimeline, 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'
|
||||
|
||||
function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
@@ -7,7 +8,68 @@ function p(partial: Partial<Part> & { type: Part['type'] }): Part {
|
||||
}
|
||||
|
||||
describe('splitTimeline', () => {
|
||||
it('keeps everything up to the last process part in the timeline', () => {
|
||||
it('keeps public Markdown before, between and after tools in reading order', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '我会先核对**两份可读材料**。', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '原始读取结果' } }),
|
||||
p({ type: 'text', text: '评审意见未能读取,完整结论尚未生成。' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c2', name: 'search' } }),
|
||||
p({ type: 'text', text: '接下来确认日期冲突。' }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.final.map(part => part.text)).toEqual([
|
||||
'我会先核对**两份可读材料**。',
|
||||
'评审意见未能读取,完整结论尚未生成。',
|
||||
'接下来确认日期冲突。',
|
||||
])
|
||||
expect(split.timeline.map(part => part.type)).toEqual(['tool_call', 'tool_result', 'tool_call'])
|
||||
})
|
||||
|
||||
it('keeps live scope limits visible while execution records are folded by default', () => {
|
||||
let items = applyThinkingDelta([], '内部分析,不向用户展示', 0)
|
||||
items = applyTextDelta(items, 0, '**范围限制:**目前仅整理两份材料,评审意见尚未读取。')
|
||||
items = applyToolCall(items, 'run_python', 'c1', '{"script":"print(42)"}')
|
||||
items = applyToolOutput(items, 'c1', '42\n')
|
||||
const live = { runId: 'r1', phase: 'tool', items, agentNotes: {}, startedAt: 0 }
|
||||
const message = liveToMessage(live)
|
||||
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'])
|
||||
})
|
||||
|
||||
it('keeps finalizing checks and internal thinking out of visible text without duplicating explanations', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', process: true, text: '评审意见尚未读取。' }),
|
||||
p({ type: 'thinking', process: true, text: '内部推理' }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'read' } }),
|
||||
p({ type: 'tool_result', process: true, toolResult: { callId: 'c1', status: 'ok', summary: '诊断输出' } }),
|
||||
p({ type: 'check', process: true, check: { passed: false, reason: '纸张不是 A4' } }),
|
||||
p({ type: 'text', text: '已整理两份材料,请补充清晰的评审意见。' }),
|
||||
]
|
||||
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.length + split.timeline.length).toBe(parts.length)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps errors and file references outside a technical record even before a later tool', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'error', process: true, error: { code: 'read.failed', message: '评审意见无法读取' } }),
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '已整理的材料.docx' } }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
p({ type: 'text', text: '已整理另外两份材料' }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
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', () => {
|
||||
const parts: Part[] = [
|
||||
p({ type: 'text', text: '先找资料', process: true }),
|
||||
p({ type: 'tool_call', process: true, toolCall: { id: 'c1', name: 'search' } }),
|
||||
@@ -16,8 +78,8 @@ describe('splitTimeline', () => {
|
||||
p({ type: 'artifact_ref', artifact: { artifactId: 'a1', name: '会议纪要.docx' } }),
|
||||
]
|
||||
const split = splitTimeline(parts)
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'artifact_ref'])
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['tool_call', 'tool_result'])
|
||||
expect(split.final.map(x => x.type)).toEqual(['text', 'text', 'artifact_ref'])
|
||||
})
|
||||
|
||||
it('keeps a card sandwiched between process parts inside the timeline', () => {
|
||||
@@ -32,7 +94,7 @@ describe('splitTimeline', () => {
|
||||
expect(split.final.map(x => x.text)).toEqual(['最终'])
|
||||
})
|
||||
|
||||
it('lifts the last narration when a finished run has no final text', () => {
|
||||
it('keeps all narrations visible even 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' } }),
|
||||
@@ -40,10 +102,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.final[0].process).toBeUndefined()
|
||||
expect(split.timeline.map(x => x.type)).toEqual(['text', 'tool_call', 'tool_call'])
|
||||
expect(splitTimeline(parts).final).toEqual([])
|
||||
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)
|
||||
})
|
||||
|
||||
it('puts a message with no process parts entirely in the final section', () => {
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// RDM 角色权限面板拒绝态(2026-10-13 8788 dev 锁死复现):rdm.role.list 被拒后
|
||||
// 表格停留「加载中…」且错误为原始 400 JSON——测纯函数两件事:占位行不再假装
|
||||
// 加载中;域管理员权限拒绝映射为人话 + 恢复通道。UI 不重算权限(KTD5),文案
|
||||
// 只描述拒绝事实,不判权限。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rolesListPlaceholder, describeRoleListError } from '../src/rdmManagement'
|
||||
|
||||
describe('rolesListPlaceholder', () => {
|
||||
it('keeps 加载中 only while loading without error', () => {
|
||||
expect(rolesListPlaceholder(null, '')).toBe('加载中…')
|
||||
})
|
||||
|
||||
it('replaces the loading row with a failure row once the fetch rejected', () => {
|
||||
expect(rolesListPlaceholder(null, 'Error: 400: {"receipt":…}')).toBe('角色清单未加载(见下方提示)')
|
||||
})
|
||||
|
||||
it('returns the empty label for an empty list and nothing otherwise', () => {
|
||||
expect(rolesListPlaceholder([], '')).toBe('暂无专业角色')
|
||||
expect(rolesListPlaceholder([{ roleId: 'r1' }], '')).toBe('')
|
||||
// 已有数据时即便残留 err 也不再渲染占位行(表格有内容即事实来源)
|
||||
expect(rolesListPlaceholder([{ roleId: 'r1' }], 'stale')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeRoleListError', () => {
|
||||
const denied = 'Error: 400: {"receipt":{"commandId":"rdm.role.list","state":"rejected",'
|
||||
+ '"reason":"rejected: 权限拒绝:weichen 不是系统管理员(全局动作 project.settings)"}}'
|
||||
|
||||
it('maps the domain-admin denial to a readable message with the transfer channel', () => {
|
||||
const got = describeRoleListError(denied)
|
||||
expect(got).toContain('不是 RDM 域管理员')
|
||||
expect(got).toContain('rdm.perm.transfer')
|
||||
expect(got).not.toContain('"receipt"')
|
||||
})
|
||||
|
||||
it('passes other errors through untouched', () => {
|
||||
expect(describeRoleListError('Error: 500: boom')).toBe('Error: 500: boom')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,421 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { api } from '../src/api'
|
||||
import { store } from '../src/store'
|
||||
import type { ActivityCard, Conversation, Message, Run, StreamEvent, SurfaceSnapshot } from '../src/contracts'
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
let reject!: (reason: unknown) => void
|
||||
const promise = new Promise<T>((ok, fail) => { resolve = ok; reject = fail })
|
||||
return { promise, resolve, reject }
|
||||
}
|
||||
function card(id = 'task', status: ActivityCard['status'] = 'active', conversationId = 'a'): ActivityCard {
|
||||
return { id, status, conversationId, projectId: 'p', kind: 'progress', title: '整理材料', createdAt: '2026-10-12T00:00:00Z', updatedAt: '2026-10-12T00:00:00Z' }
|
||||
}
|
||||
function detail(id: string, runs: Run[] = [], lastSeq = 0, resumeSeq = lastSeq) {
|
||||
return { conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation, messages: [] as Message[], runs, activeHitl: [], lastSeq, resumeSeq }
|
||||
}
|
||||
function event(seq: number, type: StreamEvent['type'], payload: unknown, runId?: string, conversationId = 'a'): StreamEvent {
|
||||
return { seq, type, payload, runId, conversationId, ts: '2026-10-12T00:00:00Z', v: 1 }
|
||||
}
|
||||
class FakeEventSource {
|
||||
onopen = null
|
||||
onerror = null
|
||||
onmessage = null
|
||||
addEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource)
|
||||
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
|
||||
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
|
||||
vi.spyOn(api, 'listCards').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listTasks').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
|
||||
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
|
||||
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
|
||||
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
|
||||
await store.openProject('p')
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
})
|
||||
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
|
||||
|
||||
describe('work projection ordering and context', () => {
|
||||
it('settles foreground completion and queued input when background events overtake its detail response', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
|
||||
store.upsertCard(card('background'))
|
||||
store.enqueueOutbox('继续整理', ['material'], ['材料'])
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const send = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
|
||||
const submission = vi.spyOn(api, 'sendMessage').mockReturnValue(send.promise)
|
||||
store.applyEvent(event(2, 'run.completed', {}, 'front'))
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
store.applyEvent(event(3, 'agent.progress', { agentRun: 'background', note: '仍在核对' }, 'background'))
|
||||
store.applyEvent(event(4, 'card.status', { cardId: 'background', status: 'done' }, 'background'))
|
||||
vi.mocked(api.listCards).mockResolvedValue([card('background', 'done')])
|
||||
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 2), messages: [
|
||||
{ id: 'reply', conversationId: 'a', runId: 'front', role: 'assistant', parts: [{ type: 'text', text: '整理完成' }], createdAt: '' },
|
||||
] })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(store.getState().runs.find(r => r.id === 'front')?.status).toBe('completed')
|
||||
expect(store.getState().messages.map(m => m.id)).toContain('reply')
|
||||
expect(store.getState().live).toBeNull()
|
||||
expect(store.getState().lastSeq).toBe(4)
|
||||
expect(store.getState().cards.find(c => c.id === 'background')?.status).toBe('done')
|
||||
expect(store.getState().outbox).toEqual([])
|
||||
expect(submission).toHaveBeenCalledWith('a', '继续整理', ['material'], expect.any(String))
|
||||
send.resolve({ message: { id: 'queued', conversationId: 'a', role: 'user', parts: [], createdAt: '' } })
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
})
|
||||
|
||||
it('waits for the newer terminal run snapshot before settling its live output', async () => {
|
||||
vi.useFakeTimers()
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '正在整理' }, 'front'))
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
store.applyEvent(event(2, 'run.failed', { code: 'failed', message: '未完成' }, 'front'))
|
||||
query.resolve(detail('a', [{ ...run, status: 'completed' }], 1))
|
||||
await pending
|
||||
expect(store.getState().runs[0].status).toBe('running')
|
||||
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '正在整理' })
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [{ ...run, status: 'failed' }], 2))
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(store.getState().runs[0].status).toBe('failed')
|
||||
expect(store.getState().live).toBeNull()
|
||||
})
|
||||
|
||||
it('does not restore a resolved HITL from a detail snapshot overtaken by its resolution', async () => {
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run]))
|
||||
await store.openConversation('a')
|
||||
const query = deferred<Awaited<ReturnType<typeof api.conversationDetail>>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
store.applyEvent(event(2, 'hitl.resolved', { cardId: 'ask' }, 'front'))
|
||||
query.resolve({ ...detail('a', [{ ...run, status: 'completed' }], 1), activeHitl: [
|
||||
{ id: 'ask', runId: 'front', conversationId: 'a', question: '继续?', kind: 'confirm', allowFreeInput: false, createdAt: '', resolved: false },
|
||||
] })
|
||||
await pending
|
||||
expect(store.getState().activeHitl).toEqual([])
|
||||
expect(store.getState().runs[0].status).toBe('completed')
|
||||
expect(store.getState().lastSeq).toBe(2)
|
||||
})
|
||||
|
||||
it('preserves a run and user message started after a detail query began', async () => {
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
vi.spyOn(api, 'sendMessage').mockResolvedValue({
|
||||
message: { id: 'new-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' },
|
||||
run: { id: 'new-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' },
|
||||
})
|
||||
await store.sendMessage('继续', [])
|
||||
query.resolve(detail('a'))
|
||||
await pending
|
||||
expect(store.getState().runs.map(r => r.id)).toEqual(['new-run'])
|
||||
expect(store.getState().messages.map(m => m.id)).toEqual(['new-message'])
|
||||
expect(store.getState().live?.runId).toBe('new-run')
|
||||
})
|
||||
|
||||
it('drops a terminal reconciliation timer after leaving its conversation', async () => {
|
||||
vi.useFakeTimers()
|
||||
store.applyEvent(event(1, 'run.completed', {}, 'front'))
|
||||
await store.openConversation('b')
|
||||
const calls = vi.mocked(api.conversationDetail).mock.calls.length
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(api.conversationDetail).toHaveBeenCalledTimes(calls)
|
||||
expect(store.getState().currentConvId).toBe('b')
|
||||
})
|
||||
|
||||
it('does not regress a finished card on an older or duplicated event', () => {
|
||||
store.applyEvent(event(10, 'card.created', { card: card() }))
|
||||
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'done' }))
|
||||
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'active' }))
|
||||
store.applyEvent(event(12, 'card.status', { cardId: 'task', status: 'failed' }))
|
||||
expect(store.getState().cards[0].status).toBe('done')
|
||||
})
|
||||
|
||||
it('rejects late card events from a different conversation', async () => {
|
||||
await store.openConversation('b')
|
||||
store.applyEvent(event(10, 'card.created', { card: card('foreign') }))
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('does not let a card query finish in another conversation', async () => {
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listCards).mockImplementation(id => id === 'a' ? query.promise : Promise.resolve([]))
|
||||
const pending = store.refreshCards('a')
|
||||
await store.openConversation('b')
|
||||
query.resolve([card('old')])
|
||||
await pending
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps card events newer than an in-flight card snapshot', async () => {
|
||||
store.applyEvent(event(10, 'card.created', { card: card() }))
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
|
||||
const pending = store.refreshCards('a')
|
||||
store.applyEvent(event(11, 'card.status', { cardId: 'task', status: 'done' }))
|
||||
store.applyEvent(event(12, 'card.created', { card: card('new-task') }))
|
||||
query.resolve([card()])
|
||||
await pending
|
||||
expect(store.getState().cards.find(c => c.id === 'task')?.status).toBe('done')
|
||||
expect(store.getState().cards.some(c => c.id === 'new-task')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves a status event that arrives before its first card snapshot', async () => {
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listCards).mockReturnValueOnce(query.promise)
|
||||
const pending = store.refreshCards('a')
|
||||
store.applyEvent(event(1, 'card.status', { cardId: 'task', status: 'done' }))
|
||||
query.resolve([card()])
|
||||
await pending
|
||||
expect(store.getState().cards[0].status).toBe('done')
|
||||
})
|
||||
|
||||
it('does not let a delayed conversation snapshot replace the newly selected one', async () => {
|
||||
const query = deferred<ReturnType<typeof detail>>()
|
||||
vi.mocked(api.conversationDetail).mockReturnValueOnce(query.promise)
|
||||
const pending = store.reconcile()
|
||||
await store.openConversation('b')
|
||||
query.resolve({ ...detail('a'), messages: [{ id: 'old-message', conversationId: 'a', role: 'user', parts: [], createdAt: '' }] })
|
||||
await pending
|
||||
expect(store.getState().currentConvId).toBe('b')
|
||||
expect(store.getState().messages).toEqual([])
|
||||
})
|
||||
|
||||
it('restores foreground output through replay without replaying old card states', async () => {
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 10, 4))
|
||||
vi.mocked(api.listCards).mockResolvedValue([card('task', 'done')])
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
store.applyEvent(event(5, 'message.delta', { index: 0, text: '已经整理' }, 'front'))
|
||||
store.applyEvent(event(6, 'card.status', { cardId: 'task', status: 'active' }))
|
||||
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '已经整理' })
|
||||
expect(store.getState().cards[0].status).toBe('done')
|
||||
})
|
||||
|
||||
it('does not duplicate replayed foreground text', async () => {
|
||||
const run: Run = { id: 'front', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' }
|
||||
vi.mocked(api.conversationDetail).mockResolvedValue(detail('a', [run], 0))
|
||||
await store.openConversation('a')
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
|
||||
store.applyEvent(event(1, 'message.delta', { index: 0, text: '结论' }, 'front'))
|
||||
expect(store.getState().live?.items).toContainEqual({ kind: 'text', index: 0, text: '结论' })
|
||||
})
|
||||
|
||||
it('background notes do not create a foreground turn or block the composer', () => {
|
||||
store.applyEvent(event(1, 'agent.progress', { agentRun: 'background', note: '正在读取' }, 'background'))
|
||||
expect(store.getState().live).toBeNull()
|
||||
})
|
||||
|
||||
it('new task pages do not replace an open document or reopen a hidden right pane', () => {
|
||||
store.requestPreview({ id: 'doc', projectId: 'p', name: '需求.md', kind: 'markdown', size: 0, createdAt: '', updatedAt: '' })
|
||||
store.setUI({ rightCollapsed: true })
|
||||
const snap: SurfaceSnapshot = { identity: { surfaceId: 'page', pluginId: 'generative-ui', owner: 'o' }, revision: 1, activationEpoch: 0, compat: { schemaVersion: 1, catalogVersion: '1' }, spec: { title: '新成果', nodes: [] } }
|
||||
store.applyEvent(event(1, 'surface.updated', snap, 'background'))
|
||||
expect(store.getState().previewActive.a).toBe('doc')
|
||||
expect(store.getState().ui.rightCollapsed).toBe(true)
|
||||
expect(store.getState().surfaces[0].identity.surfaceId).toBe('page')
|
||||
})
|
||||
|
||||
it('resets project event cursor when changing projects and drops delayed project lists', async () => {
|
||||
await store.openProject('p')
|
||||
store.applyProjectEvent(event(100, 'card.created', { card: { ...card('p-card'), conversationId: undefined } }))
|
||||
const query = deferred<ActivityCard[]>()
|
||||
vi.mocked(api.listProjectCards).mockReturnValueOnce(query.promise)
|
||||
const pending = store.loadProjectCards()
|
||||
await store.openProject('q')
|
||||
store.applyProjectEvent(event(1, 'card.created', { card: { ...card('q-card'), projectId: 'q', conversationId: undefined } }))
|
||||
query.resolve([{ ...card('old-p'), conversationId: undefined }])
|
||||
await pending
|
||||
expect(store.getState().projectCards.map(c => c.id)).toEqual(['q-card'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('shared action feedback', () => {
|
||||
it.each(['card', 'hitl'] as const)('clears its definite rejected %s action after A to B navigation', async kind => {
|
||||
const confirm = { ...card(`reject-${kind}`, 'action_required'), kind: 'confirm' as const,
|
||||
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:reject-${kind}`]] as [string, string][] }] } : {}) }
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<never>()
|
||||
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
await store.openConversation('b')
|
||||
response.reject(new Error('400: rejected'))
|
||||
await pending
|
||||
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
|
||||
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
|
||||
expect(store.getState().cards).toEqual([])
|
||||
expect(store.getState().messages).toEqual([])
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
await store.cardAction(confirm, 'confirm')
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['card', new TypeError('connection lost')], ['card', new Error('503: unavailable')],
|
||||
['hitl', new TypeError('connection lost')], ['hitl', new Error('503: unavailable')],
|
||||
] as const)('retains unknown feedback for an off-context %s request with %s', async (kind, failure) => {
|
||||
const id = `unknown-${kind}-${failure instanceof TypeError ? 'network' : 'server'}`
|
||||
const confirm = { ...card(id, 'action_required'), kind: 'confirm' as const,
|
||||
...(kind === 'hitl' ? { body: [{ type: 'kv' as const, kv: [['来源', `hitl:${id}`]] as [string, string][] }] } : {}) }
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<never>()
|
||||
const request = kind === 'hitl' ? vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise) : vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
await store.openConversation('b')
|
||||
response.reject(failure)
|
||||
await pending
|
||||
expect(store.getState().cardActionStates[confirm.id]).toBe('unknown')
|
||||
if (kind === 'hitl') expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBe('unknown')
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
await store.openConversation('a')
|
||||
await store.refreshCards('a')
|
||||
await store.cardAction(confirm, 'confirm')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
|
||||
expect(store.getState().cardActionStates[confirm.id]).toBeUndefined()
|
||||
expect(store.getState().cardActionStates[`hitl:${confirm.id}`]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not let an old rejected action clear feedback owned by a newer HITL projection', async () => {
|
||||
const confirm = { ...card('replacement', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
const old = deferred<never>()
|
||||
vi.spyOn(api, 'cardAction').mockReturnValue(old.promise)
|
||||
const first = store.cardAction(confirm, 'confirm')
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: confirm.id }))
|
||||
const projected = { ...confirm, body: [{ type: 'kv' as const, kv: [['来源', 'hitl:replacement']] as [string, string][] }] }
|
||||
store.applyEvent(event(2, 'card.updated', { card: projected }))
|
||||
const replacement = deferred<Awaited<ReturnType<typeof api.resolveHITL>>>()
|
||||
vi.spyOn(api, 'resolveHITL').mockReturnValue(replacement.promise)
|
||||
const second = store.cardAction(projected, 'confirm')
|
||||
old.reject(new Error('400: rejected'))
|
||||
await first
|
||||
expect(store.getState().cardActionStates.replacement).toBe('submitting')
|
||||
expect(store.getState().cardActionStates['hitl:replacement']).toBe('submitting')
|
||||
vi.mocked(api.listCards).mockResolvedValue([{ ...projected, status: 'resolved' }])
|
||||
replacement.resolve({ resolved: true, pivoted: false, pivotText: '' })
|
||||
await second
|
||||
expect(store.getState().cardActionStates.replacement).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not append another conversation receipt to the currently open conversation', async () => {
|
||||
const foreign = { ...card('other-confirm', 'action_required', 'b'), kind: 'confirm' as const }
|
||||
store.applyProjectEvent(event(1, 'card.created', { card: foreign }))
|
||||
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: { ...foreign, status: 'resolved' }, receipt: {
|
||||
id: 'foreign-receipt', conversationId: 'b', role: 'system', createdAt: '', parts: [{ type: 'text', text: 'other conversation outcome' }],
|
||||
} })
|
||||
await store.cardAction(foreign, 'confirm')
|
||||
expect(store.getState().messages).toEqual([])
|
||||
expect(store.getState().projectCards[0].status).toBe('resolved')
|
||||
})
|
||||
it('unlocks a completed read-only recheck even while confirmation remains pending', async () => {
|
||||
const confirm = { ...card('recheck', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
vi.spyOn(api, 'cardAction').mockResolvedValue({ card: confirm })
|
||||
await store.cardAction(confirm, 'recheck')
|
||||
expect(store.getState().cardActionStates.recheck).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not overwrite a newer resolved event with the older action response', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<{ card: ActivityCard }>()
|
||||
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
|
||||
response.resolve({ card: confirm })
|
||||
await pending
|
||||
expect(store.getState().cards[0].status).toBe('resolved')
|
||||
})
|
||||
|
||||
it('shares one lock across a raw HITL and its activity-card projection', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
|
||||
store.upsertCard(confirm)
|
||||
const response = deferred<{ resolved: boolean; pivoted: boolean; pivotText: string }>()
|
||||
const request = vi.spyOn(api, 'resolveHITL').mockReturnValue(response.promise)
|
||||
const first = store.resolveHITL('ask', { choiceId: 'yes' })
|
||||
const second = store.cardAction(confirm, 'yes')
|
||||
expect(store.getState().cardActionStates.confirm).toBe('submitting')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'resolved' }])
|
||||
response.resolve({ resolved: true, pivoted: false, pivotText: '' })
|
||||
await Promise.all([first, second])
|
||||
expect(store.getState().cardActionStates.confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('uses the server cancellation outcome rather than labelling every resolved HITL executed', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, body: [{ type: 'kv', kv: [['来源', 'hitl:ask']] }] } satisfies ActivityCard
|
||||
store.upsertCard(confirm)
|
||||
vi.spyOn(api, 'resolveHITL').mockResolvedValue({ resolved: true, pivoted: false, pivotText: '' })
|
||||
vi.mocked(api.listCards).mockResolvedValue([{ ...confirm, status: 'dismissed' }])
|
||||
await store.resolveHITL('ask', { choiceId: 'no' })
|
||||
expect(store.getState().cards[0].status).toBe('dismissed')
|
||||
})
|
||||
|
||||
it('leaves a new conversation untouched when an older send returns', async () => {
|
||||
const response = deferred<Awaited<ReturnType<typeof api.sendMessage>>>()
|
||||
vi.spyOn(api, 'sendMessage').mockReturnValue(response.promise)
|
||||
const pending = store.sendMessage('整理材料', [])
|
||||
await store.openConversation('b')
|
||||
response.resolve({ message: { id: 'old-message', conversationId: 'a', role: 'user', createdAt: '', parts: [{ type: 'text', text: '整理材料' }] }, run: { id: 'old-run', conversationId: 'a', projectId: 'p', kind: 'conversation', status: 'running', createdAt: '' } })
|
||||
await pending
|
||||
expect(store.getState().messages).toEqual([])
|
||||
expect(store.getState().live).toBeNull()
|
||||
})
|
||||
it('locks a card before awaiting so two views submit one effect', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const, actions: [{ id: 'confirm', label: '生成草稿' }] }
|
||||
const response = deferred<{ card: ActivityCard }>()
|
||||
const calls = vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
store.upsertCard(confirm)
|
||||
const first = store.cardAction(confirm, 'confirm')
|
||||
const second = store.cardAction(confirm, 'confirm')
|
||||
expect(store.getState().cardActionStates?.confirm).toBe('submitting')
|
||||
expect(calls).toHaveBeenCalledTimes(1)
|
||||
response.resolve({ card: { ...confirm, status: 'resolved' } })
|
||||
await Promise.all([first, second])
|
||||
expect(store.getState().cards[0].status).toBe('resolved')
|
||||
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps an uncertain effect disabled until a server outcome is observed', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
|
||||
store.upsertCard(confirm)
|
||||
vi.spyOn(api, 'cardAction').mockRejectedValue(new TypeError('connection lost'))
|
||||
vi.mocked(api.listCards).mockResolvedValue([confirm])
|
||||
await store.cardAction(confirm, 'confirm')
|
||||
expect(store.getState().cardActionStates?.confirm).toBe('unknown')
|
||||
store.applyEvent(event(1, 'card.resolved', { cardId: 'confirm' }))
|
||||
expect(store.getState().cardActionStates?.confirm).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not write a completed old action into a different conversation', async () => {
|
||||
const confirm = { ...card('confirm', 'action_required'), kind: 'confirm' as const }
|
||||
const response = deferred<{ card: ActivityCard }>()
|
||||
vi.spyOn(api, 'cardAction').mockReturnValue(response.promise)
|
||||
const pending = store.cardAction(confirm, 'confirm')
|
||||
await store.openConversation('b')
|
||||
response.resolve({ card: { ...confirm, status: 'resolved' } })
|
||||
await pending
|
||||
expect(store.getState().cards).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Children, createElement, isValidElement, type ReactNode } from 'react'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { TopBar } from '../src/components/TopBar'
|
||||
import { TaskSidebar } from '../src/components/TaskSidebar'
|
||||
import { selectWorkbenchLayout, type WorkbenchLayoutOptions } from '../src/workbenchLayout'
|
||||
import { store } from '../src/store'
|
||||
|
||||
vi.mock('../src/store', () => {
|
||||
const state = {
|
||||
projects: [{ id: 'prj_1', name: '材料项目' }],
|
||||
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '整理项目材料', updatedAt: '2026-10-12T10:00:00Z' }],
|
||||
me: null, adminSections: [], artifacts: [], cards: [], projectCards: [], serverWorkspaces: [],
|
||||
rdmAuditAvailable: false, currentConvId: 'cnv_1', currentProjectId: 'prj_1',
|
||||
ui: { theme: 'light', showArchived: false, rightCollapsed: true, rightTab: 'work' },
|
||||
}
|
||||
return { useStore: (select: (value: typeof state) => unknown) => select(state), store: {
|
||||
openInbox: vi.fn(), openFiles: vi.fn(), focusPending: vi.fn(), setUI: vi.fn(),
|
||||
} }
|
||||
})
|
||||
|
||||
const desktop: WorkbenchLayoutOptions = {
|
||||
width: 1440, contextOpen: false, leftCollapsed: false, rightCollapsed: false,
|
||||
leftWidth: 260, rightWidth: 360, previewWidth: 700, rightTab: 'work',
|
||||
previewOpen: false, previewExpanded: false, filesView: 'list',
|
||||
}
|
||||
|
||||
describe('selectWorkbenchLayout', () => {
|
||||
it.each([320, 390, 736])('shows one conversation pane at %ipx and keeps saved desktop widths unchanged', width => {
|
||||
const options = { ...desktop, width, leftWidth: 420, rightWidth: 560, rightCollapsed: true }
|
||||
const layout = selectWorkbenchLayout(options)
|
||||
expect(layout.compact).toBe(true)
|
||||
expect([layout.showContext, layout.showConversation, layout.showRight]).toEqual([false, true, false])
|
||||
expect(options.leftWidth).toBe(420)
|
||||
expect(options.rightWidth).toBe(560)
|
||||
})
|
||||
|
||||
it('opens context on a narrow screen and returns to the existing conversation', () => {
|
||||
const options = { ...desktop, width: 390, rightCollapsed: true }
|
||||
expect(selectWorkbenchLayout({ ...options, contextOpen: true }).pane).toBe('context')
|
||||
expect(selectWorkbenchLayout({ ...options, contextOpen: false }).pane).toBe('conversation')
|
||||
})
|
||||
|
||||
it('uses the same right pane for work and expanded file reading', () => {
|
||||
const work = selectWorkbenchLayout({ ...desktop, width: 390 })
|
||||
expect(work.pane).toBe('work')
|
||||
expect([work.showContext, work.showConversation, work.showRight]).toEqual([false, false, true])
|
||||
const files = selectWorkbenchLayout({ ...desktop, width: 390, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
|
||||
expect(files.pane).toBe('files')
|
||||
expect([files.showContext, files.showConversation, files.showRight]).toEqual([false, false, true])
|
||||
expect(files.previewExpanded).toBe(true)
|
||||
expect(files.rightWidth).toBe(382)
|
||||
})
|
||||
|
||||
it('keeps the conversation visible after an expanded file pane is closed', () => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true, rightCollapsed: true })
|
||||
expect(layout.showConversation).toBe(true)
|
||||
expect(layout.showRight).toBe(false)
|
||||
expect(layout.previewExpanded).toBe(false)
|
||||
})
|
||||
|
||||
it.each([1024, 1440])('fits stored oversized work panes inside %ipx while reserving conversation space', width => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, width, leftWidth: 4000, rightWidth: 4000 })
|
||||
expect(layout.compact).toBe(false)
|
||||
expect(layout.leftWidth).toBeGreaterThanOrEqual(220)
|
||||
expect(layout.leftWidth).toBeLessThanOrEqual(420)
|
||||
expect(layout.rightWidth).toBeGreaterThanOrEqual(280)
|
||||
expect(layout.rightWidth).toBeLessThanOrEqual(560)
|
||||
expect(layout.leftWidth + layout.rightWidth + 360 + 32).toBeLessThanOrEqual(width)
|
||||
})
|
||||
|
||||
it('fits wide file reading at 1024px without discarding the conversation', () => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, width: 1024, leftWidth: 420, previewWidth: 1100, rightTab: 'files', filesView: 'preview', previewOpen: true })
|
||||
expect(layout.showConversation).toBe(true)
|
||||
expect(layout.rightWidth).toBeGreaterThanOrEqual(420)
|
||||
expect(layout.leftWidth + layout.rightWidth + 320 + 32).toBeLessThanOrEqual(1024)
|
||||
})
|
||||
|
||||
it('enlarges desktop reading inside the existing right pane and rejects invalid saved widths', () => {
|
||||
const layout = selectWorkbenchLayout({ ...desktop, leftWidth: NaN, previewWidth: Infinity, rightTab: 'files', filesView: 'preview', previewOpen: true, previewExpanded: true })
|
||||
expect(layout.showConversation).toBe(false)
|
||||
expect(layout.showRight).toBe(true)
|
||||
expect(layout.previewExpanded).toBe(true)
|
||||
expect(Number.isFinite(layout.leftWidth + layout.rightWidth)).toBe(true)
|
||||
expect(layout.leftWidth + layout.rightWidth + 20).toBeLessThanOrEqual(1440)
|
||||
})
|
||||
})
|
||||
|
||||
describe('workbench navigation', () => {
|
||||
function action(node: ReactNode, label: string): (() => void) | undefined {
|
||||
for (const child of Children.toArray(node)) {
|
||||
if (!isValidElement<{ children?: ReactNode; 'aria-label'?: string; onClick?: () => void }>(child)) continue
|
||||
if (child.props['aria-label'] === label) return child.props.onClick
|
||||
const found = action(child.props.children, label)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps work and file entrances available when the auxiliary pane is closed', () => {
|
||||
const html = renderToStaticMarkup(createElement(TopBar))
|
||||
expect(html).toContain('aria-label="打开工作"')
|
||||
expect(html).toContain('aria-label="打开文件"')
|
||||
})
|
||||
|
||||
it('offers a clear return to the conversation while reading a file on a narrow screen', () => {
|
||||
const html = renderToStaticMarkup(createElement<NonNullable<Parameters<typeof TopBar>[0]>>(TopBar, {
|
||||
compact: true, activePane: 'files', onConversation: () => undefined,
|
||||
}))
|
||||
expect(html).toContain('aria-label="返回对话"')
|
||||
for (const name of ['打开上下文', '打开工作', '打开文件']) expect(html).toContain(`aria-label="${name}"`)
|
||||
})
|
||||
|
||||
it('opens ordinary work with all results reachable instead of only pending confirmations', () => {
|
||||
action(TopBar(), '打开工作')?.()
|
||||
expect(store.openInbox).toHaveBeenCalledOnce()
|
||||
expect(store.setUI).toHaveBeenCalledWith({ inboxFilter: 'all', previewExpanded: false })
|
||||
})
|
||||
|
||||
it('connects both the return action and conversation navigation to the same return path', () => {
|
||||
const onConversation = vi.fn()
|
||||
const topbar = TopBar({ compact: true, activePane: 'files', onConversation })
|
||||
action(topbar, '返回对话')?.()
|
||||
action(topbar, '打开对话')?.()
|
||||
expect(onConversation).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('names conversation creation accurately and exposes conversation selection to the keyboard', () => {
|
||||
const html = renderToStaticMarkup(createElement(TaskSidebar))
|
||||
expect(html).toContain('新建对话')
|
||||
expect(html).not.toContain('新建任务')
|
||||
expect(html).toMatch(/<button[^>]*class="tsb-task-open"/)
|
||||
expect(html).toContain('更多对话操作')
|
||||
expect(html.match(/材料项目/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user