From 1d7f2a3e1b2b77f4f2690b80fa8b7481c9d15f87 Mon Sep 17 00:00:00 2001 From: weichen Date: Thu, 22 Oct 2026 03:18:08 +0000 Subject: [PATCH 1/2] Add the no-project conversation view backed by scratch sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: Accounts without project.create need isolated general-agent chats. The sidebar groups every scratch session under one "无项目对话" entry instead of the project switcher, and the client no longer auto-creates a default project. Evidence: npx vitest run (64 files, 677 tests) and npx tsc -b --noEmit pass locally (SELF); CI on the PR head is authoritative. Impact: createTask uses POST /api/scratch-sessions in scratch mode or when no real project exists; scratch rows hide cross-read toggles and project settings; "新建项目…" stays gated by project.create. Refs: Agent-System-Dev docs/superpowers/specs/2026-10-22-scratch-session-general-agent-design.md; agent-exec receipt u3-run5 (cursor, success) Co-authored-by: Cursor --- src/api.ts | 1 + src/components/TaskSidebar.tsx | 43 +++++++---- src/contracts.ts | 2 + src/store.test.ts | 24 +++++- src/store.ts | 112 ++++++++++++++++++++++------ test/scratch-session.test.ts | 129 +++++++++++++++++++++++++++++++++ 6 files changed, 270 insertions(+), 41 deletions(-) create mode 100644 test/scratch-session.test.ts diff --git a/src/api.ts b/src/api.ts index eff28e4..4aea859 100644 --- a/src/api.ts +++ b/src/api.ts @@ -117,6 +117,7 @@ export const api = { explainSystemAction: (action: string) => auth.fetch(`${BASE}/api/authz/explain?action=${encodeURIComponent(action)}`).then(r => json<{ allowed: boolean; actionId: string; source?: string; reason?: string; authorityEpoch: number }>(r)), listProjects: (signal?: AbortSignal) => auth.fetch(`${BASE}/api/projects`, signal ? { signal } : undefined).then(r => json(r)), createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), + createScratchSession: (title?: string) => auth.fetch(`${BASE}/api/scratch-sessions`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(title ? { title } : {}) }).then(r => json<{ project: Project; conversation: Conversation }>(r)), renameProject: (id: string, name: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json(r)), deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked), diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx index b14f2a3..9421142 100644 --- a/src/components/TaskSidebar.tsx +++ b/src/components/TaskSidebar.tsx @@ -55,6 +55,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, const artifacts = useStore(s => s.artifacts) const currentConvId = useStore(s => s.currentConvId) const currentProjectId = useStore(s => s.currentProjectId) + const scratchMode = useStore(s => !!s.scratchMode) const showArchived = useStore(s => s.ui.showArchived) const serverWorkspaces = useStore(s => s.serverWorkspaces) /** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */ @@ -74,13 +75,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, const createConversation = async (projectId?: string) => { try { + if (projectId && scratchMode) await store.openProject(projectId) await store.createTask(projectId) onConversationOpen?.() } catch { /* store owns the actionable failure toast */ } } const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable }) - const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q) + const scratchTasks = tasks.filter(t => t.scratch) + const visible = filterTasks(scratchMode ? scratchTasks : tasks.filter(t => t.projectId === currentProjectId && !t.scratch), q) const pinned = visible.filter(t => t.pinned) const rest = visible.filter(t => !t.pinned) const currentProject = projects.find(p => p.id === currentProjectId) @@ -99,10 +102,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, - + } ) @@ -121,7 +124,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, {rowActions(t)} @@ -136,9 +139,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
@@ -147,7 +150,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, {/* 主入口创建会话;项目创建保留在次级菜单。 */}
- @@ -156,12 +160,12 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
- setQ(e.target.value)} /> + setQ(e.target.value)} />
- 本项目会话 + {scratchMode ? '无项目对话' : '本项目会话'} @@ -195,7 +199,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, )} {rest.length === 0 && pinned.length === 0 && ( -
{noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) +
{scratchMode ? '开始一个通用对话;无项目对话彼此独立,不会使用项目文档与知识。' + : noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : workError ?? '从「新建对话」开始,描述你想完成的工作。'}
)} {rest.map(t => taskRow(t))} @@ -215,7 +220,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen, {entry.shortcut} ))} - {currentProject && ( + {currentProject && !scratchMode && ( - 实际读取范围还需项目设置允许
+ 实际读取范围还需项目设置允许
} 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') + } + }) +})