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}
store.setInboxScope('conv')}>本会话
- store.setInboxScope('project')}>
+ {!scratch && store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
-
+ }
store.setUI({ inboxFilter: 'todo' })}>待处理
store.setUI({ inboxFilter: 'all' })}>全部
{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')
+ }
+ })
+})