diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 74d20fd..5c1832f 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -637,14 +637,14 @@ function ConversationSession() { {profiles.map(p => )} -
读取同项目的其他对话 - 实际读取范围还需项目设置允许
+ 实际读取范围还需项目设置允许} diff --git a/src/components/ConversationWork.tsx b/src/components/ConversationWork.tsx index bfb9f5c..31f08c2 100644 --- a/src/components/ConversationWork.tsx +++ b/src/components/ConversationWork.tsx @@ -10,6 +10,7 @@ export function ConversationWork() { const cards = useStore(s => s.cards) const conversationId = useStore(s => s.currentConvId) const projectId = useStore(s => s.currentProjectId) + const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch) const messages = useStore(s => s.messages) const actionStates = useStore(s => s.cardActionStates) const rightCollapsed = useStore(s => s.ui.rightCollapsed) @@ -56,7 +57,7 @@ export function ConversationWork() { 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) }, + ...(scratch ? {} : { onArchive: (card: ActivityCard) => { void store.cardToProject(card) } }), } const view = (card: ActivityCard) => diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 7cea166..e36f95c 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -22,7 +22,8 @@ export const docBridge: DocBridge = { }, } -const inboxCallbacks = { +// 无项目对话(scratch)没有可留档的项目:只用不含 onArchive 的回调。 +const scratchInboxCallbacks = { onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload), onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c), onReference: (c: ActivityCard) => store.referenceWork(c), @@ -30,8 +31,8 @@ const inboxCallbacks = { onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p), onDismiss: (c: ActivityCard) => store.cardDismiss(c), onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId), - onArchive: (c: ActivityCard) => store.cardToProject(c), } +const inboxCallbacks = { ...scratchInboxCallbacks, onArchive: (c: ActivityCard) => store.cardToProject(c) } /** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) { @@ -63,7 +64,9 @@ export function Workbench() { const cards = useStore(s => s.cards) const plan = useStore(s => s.plan) const todos = useStore(s => s.todos) - const inboxScope = useStore(s => s.inboxScope) + const scratch = useStore(s => !!s.tasks.find(t => t.id === s.currentConvId)?.scratch) + const storedScope = useStore(s => s.inboxScope) + const inboxScope = scratch ? 'conv' : storedScope const projectCards = useStore(s => s.projectCards) const filter = useStore(s => s.ui.inboxFilter) const visible = inboxScope === 'project' ? projectCards : cards @@ -87,7 +90,7 @@ export function Workbench() { {rightTab === 'files' ? - : } + : } ) } @@ -105,13 +108,14 @@ export function hasActiveProgressCard(cards: ActivityCard[]): boolean { return cards.some(c => c.kind === 'progress' && (c.status === 'active' || c.status === 'action_required')) } -function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: { +function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards, scratch }: { plan: Parameters[0]['plan'] todos: Parameters[0]['todos'] filter: InboxFilter inboxScope: 'conv' | 'project' projectCards: ActivityCard[] cards: ActivityCard[] + scratch: boolean }) { const record = useStore(s => s.workRecord) const showCurrentTask = !hasActiveProgressCard(cards) @@ -122,16 +126,16 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: { {showCurrentTask ? : null}
- + }
{inboxScope === 'project' ? - : } + : } } diff --git a/test/scratch-surfaces.test.ts b/test/scratch-surfaces.test.ts new file mode 100644 index 0000000..002c7be --- /dev/null +++ b/test/scratch-surfaces.test.ts @@ -0,0 +1,92 @@ +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 | undefined })) +vi.mock('../src/store', async importOriginal => { + const actual = await importOriginal() + return { ...actual, useStore: (select: (s: ReturnType) => 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[], card: [] as Record[] })) +vi.mock('@ui', async importOriginal => { + const actual = await importOriginal() + return { + ...actual, + InboxList: (props: { callbacks: Record }) => { passed.inbox.push(props.callbacks); return null }, + ActivityCardView: (props: { callbacks: Record }) => { 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') + } + }) +})