diff --git a/e2e/work-companion-live.playwright.ts b/e2e/work-companion-live.playwright.ts new file mode 100644 index 0000000..b3be563 --- /dev/null +++ b/e2e/work-companion-live.playwright.ts @@ -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([]) +}) diff --git a/e2e/work-companion-navigation.playwright.ts b/e2e/work-companion-navigation.playwright.ts new file mode 100644 index 0000000..de01844 --- /dev/null +++ b/e2e/work-companion-navigation.playwright.ts @@ -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(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([]) + }) +} diff --git a/e2e/work-companion.playwright.ts b/e2e/work-companion.playwright.ts new file mode 100644 index 0000000..d3e7211 --- /dev/null +++ b/e2e/work-companion.playwright.ts @@ -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 { + 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 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(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([]) + }) +} diff --git a/src/App.tsx b/src/App.tsx index ab4dffc..6cf56a9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 setAuthState('ok')} @@ -132,22 +184,20 @@ export function App() { const tools = workspaceEntries() return ( -
- -
- {!ui.leftCollapsed ? ( - <> -
- +
+ +
+ + {layout.leftDivider &&
} - +
+ +
- {!ui.rightCollapsed ? ( - <> -
-
- -
- - ) : ( -
-
- - -
-
- )} + {layout.rightDivider &&
} +
diff --git a/src/companion-conversation.css b/src/companion-conversation.css new file mode 100644 index 0000000..5402689 --- /dev/null +++ b/src/companion-conversation.css @@ -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; } diff --git a/src/companion-shell.css b/src/companion-shell.css new file mode 100644 index 0000000..3ee248d --- /dev/null +++ b/src/companion-shell.css @@ -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; } diff --git a/src/components/ArtifactsPanel.tsx b/src/components/ArtifactsPanel.tsx index d570cd8..3466dd4 100644 --- a/src/components/ArtifactsPanel.tsx +++ b/src/components/ArtifactsPanel.tsx @@ -59,7 +59,7 @@ export function ArtifactsPanel() {
)} {artifacts.length === 0 - ?
暂无文件——上传附件或让模型产出文档(mock:#doc);这里可浏览、预览、下载
+ ?
暂无文件,可添加材料或在对话中生成文档
: visible.length === 0 ?
没有匹配「{q}」的文件
: groups.map(g => g.versions.length === 1 diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 72b4948..2cbd1a6 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -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 !!findArtifact(id))} /> case 'thinking': - // 流式期间展开可实时看到思考;run 终态后收进过程折叠区,默认收起。 - //(此单件形态用于过程组之外散落的思考;组内思考由 ProcessParts 渲染。) - return + return null case 'tool_call': return 🛠 {part.toolCall?.name} 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 ( -
- ▸思考过程}> -
{part.text}
-
-
- ) -} - // 交付物卡片:主区域和「预览」打开预览区;下载收进菜单,避免和预览抢同一个按钮。 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 ( - {livePhaseLabel(live)} + {label} 已工作 {elapsed} ) @@ -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 = ( } /> ) 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 助手 {live && }
- {split.timeline.length > 0 && ( + {split.timeline.some(part => part.type !== 'thinking') && (
@@ -268,7 +259,8 @@ function AssistantMessage({ msg, live, convId }: { msg: Message; live?: LiveStat {extras.map(art => ( ))} - {live && } + {live && Object.keys(live.agentNotes).length > 0 && 协作记录}>} {!live && ctxObs && } {convId && }
@@ -282,25 +274,32 @@ function SessionNoteField({ convId, note }: { convId: string; note: string }) { 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 (
- - - {msg.role === 'assistant' && ( - - )} - {msg.variantGroup && msg.excluded && ( - - )} + + +
+ + {msg.role === 'assistant' && ( + + )} + {msg.variantGroup && msg.excluded && ( + + )} + {msg.role === 'user' && } +
+
{msg.variantGroup && 候选 {(msg.variantIndex ?? 0) + 1}}
) @@ -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 (
-
{card.question}
- {card.detail &&
{card.detail}
} +
{question}
+ {card.blocked &&
+ 操作受限:{card.blockReason || '当前操作条件尚未满足'} +
} + {detail &&
▸操作详情}> +
{detail}
+
} + {actionState &&
+ {actionState === 'unknown' ? '正在核实处理结果' : '正在提交你的选择'} +
} {card.choices && (
{card.choices.map(c => ( @@ -373,7 +388,7 @@ function HITLCardView({ card }: { card: HITLCard }) { {card.allowFreeInput && (
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() }) }} />
)} @@ -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 +} + +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([]) const [attachmentNames, setAttachmentNames] = useState([]) const [dragDepth, setDragDepth] = useState(0) + const [uploading, setUploading] = useState(0) + const [uploadError, setUploadError] = useState('') + const composeActive = useRef(true) + const inputRef = useRef(null) const bodyRef = useRef(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 ( -
-
-
⌘
-
选择或创建一个会话开始
- {runtimeMock - ?
mock 模式演示指令:#hitl · #agents · #doc · #error
- :
演示模式可在设置里打开,无需模型密钥
} +
+
+ +
从一件需要完成的工作开始
+
告诉助手你的目标,或准备好需要一起阅读的材料
+
) } return ( -
0 ? 'is-dragging' : ''}`} +
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 &&
释放以上传资料
}
- {projectName}
{conversation?.title ?? '会话'}
- { 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 => )} - - - - {!connected && 重连中} + 实际读取范围还需项目设置允许
+ + +
{m.parts.filter(p => p.type === 'text').map(p => p.text).join('\n')}
{currentConvId && } - {currentConvId && } {anchored.map(c => ( 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 && } {activeHitl.map(c => )} - {messages.length === 0 &&
+ + {messages.length === 0 && !live && cards.length === 0 && activeHitl.length === 0 &&
-
发送消息开始对话
- {runtimeMock &&
mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help
} +
这次想完成什么
+
说说目标,也可以先添加材料
+
+ {['帮我整理这些材料中的重点和缺项', '一起比较这两个方案,说明各自适合的情况', '帮我把已有想法整理成一份草稿'].map(example => ( + + ))} +
}
{showJump && ( @@ -616,59 +674,59 @@ export function Conversation() {
+ {!connected &&
正在重新连接,最新进展待确认
} {/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队; 后台任务在回合结束后仍在执行,完成后回到对话汇报。 */} {(busy || bgRunning > 0) && (
{busy - ? 助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''} - : {bgRunning} 个后台任务进行中} - {busy ? '输入仍可排队' : '完成后回到对话汇报'} - - + ? 助手正在处理{bgRunning > 0 ? ` · ${bgRunning} 项后台工作` : ''} + : {bgRunning} 项后台工作} + {busy ? '新消息会排队,当前回合结束后发送' : '你可以继续对话,完成后会在这里汇报'}
)} {attachmentNames.length > 0 && (
{attachmentNames.map((n, i) => {n} - { setAttachmentIds(a => a.filter((_, j) => j !== i)); setAttachmentNames(a => a.filter((_, j) => j !== i)) }}> + )}
)} + {uploading > 0 &&
正在添加 {uploading} 份材料,完成后即可发送
} + {uploadError &&
{uploadError}
} + 操作设置} bodyClassName="companion-operation-body"> + +

计划模式只进行读取与规划;自动改文件模式允许文件修改。重要操作仍按当前权限规则确认

+
{text.startsWith('/') && setText(c)} />}
- - -