Why: Independent review found the cross-read toggle, the project inbox scope and the archive-to-project action still reachable from an open scratch (no-project) conversation. Evidence: npx vitest run (685 tests) and npx tsc -b --noEmit pass locally against the UI fix branch (SELF); CI on the PR head is authoritative. Impact: Scratch conversations hide the cross-read setting, always show the conversation inbox and omit onArchive; real projects are unchanged. Refs: Agent-System-UI#60; agent-exec receipt u3e-run (cursor, success) Co-authored-by: Cursor <cursoragent@cursor.com>
93 lines
4.4 KiB
TypeScript
93 lines
4.4 KiB
TypeScript
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 } from '../src/contracts'
|
|
import { Conversation } from '../src/components/Conversation'
|
|
import { ConversationWork } from '../src/components/ConversationWork'
|
|
import { Workbench } from '../src/components/Workbench'
|
|
import { store } from '../src/store'
|
|
|
|
// SSR has no browser subscription snapshot: substitute only the hook boundary.
|
|
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!) }
|
|
})
|
|
|
|
// Record the callbacks each card surface receives; the rendered archive button belongs to the UI repo.
|
|
const passed = vi.hoisted(() => ({ inbox: [] as Record<string, unknown>[], card: [] as Record<string, unknown>[] }))
|
|
vi.mock('@ui', async importOriginal => {
|
|
const actual = await importOriginal<typeof import('@ui')>()
|
|
return {
|
|
...actual,
|
|
InboxList: (props: { callbacks: Record<string, unknown> }) => { passed.inbox.push(props.callbacks); return null },
|
|
ActivityCardView: (props: { callbacks: Record<string, unknown> }) => { passed.card.push(props.callbacks); return null },
|
|
}
|
|
})
|
|
|
|
const timestamp = '2026-10-12T10:00:00Z'
|
|
const card: ActivityCard = {
|
|
id: 'card_1', projectId: 'prj_1', conversationId: 'cnv_1', kind: 'progress', title: '整理材料',
|
|
status: 'active', tone: 'info', body: [], createdAt: timestamp, updatedAt: timestamp,
|
|
}
|
|
|
|
function setup(scratch: boolean) {
|
|
const base = store.getState()
|
|
snapshot.state = {
|
|
...base, currentProjectId: 'prj_1', currentConvId: 'cnv_1', connected: true, inboxScope: 'project',
|
|
tasks: [{ id: 'cnv_1', projectId: 'prj_1', title: '讨论', createdAt: timestamp, updatedAt: timestamp, ...(scratch ? { scratch: true } : {}) }],
|
|
messages: [], cards: [card], projectCards: [], runs: [], activeHitl: [], live: null, artifacts: [], outbox: [], posting: false,
|
|
ui: { ...base.ui, rightCollapsed: true, rightTab: 'work', inboxFilter: 'all' },
|
|
cardActionStates: {}, workRecord: null, surfaces: [],
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
passed.inbox = []; passed.card = []
|
|
setPersistedOpen('conversation-settings:prj_1:cnv_1', true)
|
|
})
|
|
|
|
describe.each([
|
|
{ name: 'scratch conversation', scratch: true },
|
|
{ name: 'project conversation', scratch: false },
|
|
])('project-only surfaces in a $name', ({ scratch }) => {
|
|
beforeEach(() => setup(scratch))
|
|
|
|
it(scratch ? 'hides the cross-conversation read setting' : 'keeps the cross-conversation read setting', () => {
|
|
const html = renderToStaticMarkup(createElement(Conversation))
|
|
expect(html).toContain('会话设置')
|
|
expect(html).toContain('这次工作的要求')
|
|
expect(html.includes('读取同项目的其他对话')).toBe(!scratch)
|
|
expect(html.includes('实际读取范围还需项目设置允许')).toBe(!scratch)
|
|
})
|
|
|
|
it(scratch ? 'keeps the conversation inbox without the project scope' : 'keeps the project scope and inbox', () => {
|
|
const html = renderToStaticMarkup(createElement(Workbench))
|
|
expect(html).toContain('本会话')
|
|
expect(html.includes('>项目')).toBe(!scratch)
|
|
expect(html.includes('项目收件箱为空')).toBe(!scratch)
|
|
if (scratch) {
|
|
expect(passed.inbox).toHaveLength(1)
|
|
expect(passed.inbox[0]).not.toHaveProperty('onArchive')
|
|
expect(passed.inbox[0]).toHaveProperty('onDismiss')
|
|
}
|
|
})
|
|
|
|
it(scratch ? 'omits archive-to-project from conversation inbox cards' : 'offers archive-to-project on conversation inbox cards', () => {
|
|
snapshot.state!.inboxScope = 'conv'
|
|
renderToStaticMarkup(createElement(Workbench))
|
|
expect(passed.inbox).toHaveLength(1)
|
|
expect(typeof passed.inbox[0].onArchive).toBe(scratch ? 'undefined' : 'function')
|
|
})
|
|
|
|
it(scratch ? 'omits archive-to-project from conversation work cards' : 'offers archive-to-project on conversation work cards', () => {
|
|
renderToStaticMarkup(createElement(ConversationWork))
|
|
expect(passed.card.length).toBeGreaterThan(0)
|
|
for (const callbacks of passed.card) {
|
|
expect(typeof callbacks.onArchive).toBe(scratch ? 'undefined' : 'function')
|
|
expect(callbacks).toHaveProperty('onDismiss')
|
|
}
|
|
})
|
|
})
|