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/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/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 && ( - + }
{inboxScope === 'project' ? - : } + : } }
diff --git a/src/contracts.ts b/src/contracts.ts index 41fa4b3..358edfd 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -18,6 +18,8 @@ export interface Conversation { allowCrossRead?: boolean; crossPrivate?: boolean; permissionMode?: 'default' | 'plan' | 'auto_edit' | ''; sessionNote?: string; + /** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */ + scratch?: boolean; createdAt: string; updatedAt: string; } diff --git a/src/store.test.ts b/src/store.test.ts index cbd3fae..416e752 100644 --- a/src/store.test.ts +++ b/src/store.test.ts @@ -306,14 +306,27 @@ describe('RefreshReconnectCompensate', () => { }) }) +function routeScratchSession() { + const previous = route + route = (path, init) => { + if (path === '/api/scratch-sessions' && init?.method === 'POST') { + return Promise.resolve(response({ project: project('scratch-1'), conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true } })) + } + if (path === '/api/conversations/scratch-conv') return Promise.resolve(response({ conversation: { ...task('scratch-conv', 'scratch-1'), scratch: true }, messages: [], runs: [], activeHitl: [], lastSeq: 0 })) + return previous(path, init) + } +} + describe('DefaultProjectDisabled', () => { it('does not create a default project when the member has no projects yet', async () => { projects = [] await store.refreshProjects() + routeScratchSession() requests = [] - await expect(store.createTask()).rejects.toThrow(/等待项目负责人邀请/) - expect(requests.filter(r => r.init?.method === 'POST')).toEqual([]) - expect(store.getState().toast).toMatch(/等待项目负责人邀请/) + await store.createTask() + const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path) + expect(posts).toEqual(['/api/scratch-sessions']) + expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' }) }) it('does not treat a list that has not arrived as empty', async () => { @@ -335,9 +348,12 @@ describe('DefaultProjectDisabled', () => { await store.refreshMe() projects = [] await store.refreshProjects() + routeScratchSession() requests = [] - await expect(store.createTask()).rejects.toThrow(/请先新建项目/) + await store.createTask() expect(requests.filter(r => r.path === '/api/projects' && r.init?.method === 'POST')).toEqual([]) + expect(requests.filter(r => r.init?.method === 'POST').map(r => r.path)).toEqual(['/api/scratch-sessions']) + expect(store.getState().projects).toEqual([]) }) it('surfaces the stage-specific project error before any POST is sent', async () => { diff --git a/src/store.ts b/src/store.ts index ccb8cff..ec2700c 100644 --- a/src/store.ts +++ b/src/store.ts @@ -3,7 +3,7 @@ import { useSyncExternalStore } from 'react' import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api' import { auth } from './auth' -import { canCreateProject, noProjectError, projectActionError, projectWorkError } from './projectmeta' +import { noProjectError, projectActionError, projectWorkError } from './projectmeta' import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live' import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs' import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox' @@ -85,6 +85,8 @@ export interface UIState { taskOrg: 'project' | 'timeline' showArchived: boolean lastProjectId: string | null + /** 用户上次停留在无项目对话视图;刷新后回到同一视图。 */ + lastScratch: boolean /** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */ adminZoneOpen: boolean changePwOpen: boolean @@ -93,6 +95,11 @@ export interface UIState { export interface Me { owner: string; name: string; auth: boolean; isAdmin?: boolean; isSuperAdmin?: boolean; mustChangePassword?: boolean; systemActions?: string[]; linkedCapabilities?: LinkedCapabilities } +/** 能否新建真实项目(无项目对话不需要此能力);服务端仍按当前身份最终判定。 */ +export function canCreateProject(me: Pick | null | undefined): boolean { + return !!me && (!me.auth || !!me.systemActions?.includes('project.create')) +} + /** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */ export const PROJECT_SYNC_INTERVAL_MS = 2000 export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000 @@ -107,6 +114,8 @@ interface AppState { projectsLoaded: boolean projectSync: { status: ProjectSyncStatus; failures: number } currentProjectId: string | null + /** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */ + scratchMode: boolean tasks: Conversation[] me: Me | null currentConvId: string | null @@ -181,6 +190,7 @@ filesView: 'list', workspaceSurfacePin: null, taskOrg: 'project', showArchived: false, lastProjectId: null, + lastScratch: false, adminZoneOpen: false, changePwOpen: false, projectSettingsId: null, @@ -210,7 +220,7 @@ const surfaceActionsInflight: Record = {} let state: AppState = { contextObs: {}, projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 }, - currentProjectId: null, tasks: [], me: null, currentConvId: null, + currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [], serverWorkspaces: [], @@ -348,6 +358,29 @@ function resetProjectScope() { setUI({ previewExpanded: false, filesView: 'list' }) } +/** remember=false 用于没有真实项目时的自动进入:不改写用户上次选择的视图。 */ +function setScratchMode(on: boolean, remember = true) { + if (state.scratchMode !== on) set({ scratchMode: on }) + if (remember && state.ui.lastScratch !== on) setUI({ lastScratch: on }) +} + +/** 绑定会话所属项目(真实项目或无项目对话的内部项目),供上传与工件归属。 */ +async function bindProject(projectId: string, remember: boolean) { + if (state.currentProjectId !== projectId) resetProjectScope() + set({ currentProjectId: projectId }) + if (state.inboxScope === 'project') { + store.loadProjectCards() + store.connectProject() + } + if (remember) setUI({ lastProjectId: projectId }) + await store.refreshArtifacts(projectId) +} + +function latestScratchTask(): Conversation | undefined { + return state.tasks.filter(t => t.scratch && !t.archived) + .reduce((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined) +} + function observeDetail(observations: Record, id: string, seq = state.lastSeq, terminal = false) { const version = ++detailVersion observations[id] = { ...observations[id], version, seq, @@ -582,18 +615,22 @@ async function mergeProjectList(projects: Project[]) { const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects) set({ projects, projectsLoaded: true, error: null }) const current = state.currentProjectId - if (current && !projects.some(p => p.id === current)) { + // 无项目对话视图绑定的是不在列表里的内部项目,列表变化不切换视图。 + if (state.scratchMode) { /* keep the scratch view */ } + else if (current && !projects.some(p => p.id === current)) { const fallback = projects[0] if (fallback) await store.openProject(fallback.id) else { resetProjectScope() set({ currentProjectId: null }) setUI({ lastProjectId: null }) + setScratchMode(true, false) } store.toast('你已不在原项目中,已清除该项目的入口') } else if (!current) { const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0] if (next) await store.openProject(next.id) + else setScratchMode(true, false) } if (membershipChanged) await store.refreshTasks() } @@ -617,7 +654,7 @@ export const store = { for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key] convRestoreDone = true persistConvId(null) - set({ projects: [], projectsLoaded: false, currentProjectId: null, tasks: [], me: null, currentConvId: null, + set({ projects: [], projectsLoaded: false, currentProjectId: null, scratchMode: false, tasks: [], me: null, currentConvId: null, contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', @@ -626,7 +663,7 @@ export const store = { rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES, connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false, previewTabs: {}, previewActive: {}, previewOpenIntent: 0 }) - setUI({ lastProjectId: null, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null, + setUI({ lastProjectId: null, lastScratch: false, projectSettingsId: null, adminZoneOpen: false, activeWorkspace: null, settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null, previewExpanded: false, filesView: 'list', showArchived: false }) }, @@ -634,16 +671,23 @@ export const store = { async refreshProjects() { const epoch = identityEpoch const request = ++projectsRequest + let scratch = false try { const projects = await api.listProjects() if (epoch !== identityEpoch || request !== projectsRequest) return - const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0] - set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null }) - if (current) await store.openProject(current.id) + // 没有真实项目(或上次停留在无项目对话)时进入无项目对话视图,而不是停在空项目上。 + scratch = projects.length === 0 || state.ui.lastScratch + if (scratch) set({ projects, projectsLoaded: true, error: null }) + else { + const current = projects.find(p => p.id === state.currentProjectId) ?? projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0] + set({ projects, projectsLoaded: true, currentProjectId: current?.id ?? null, error: null }) + if (current) await store.openProject(current.id) + } } catch (e) { if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) }) } if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks() + if (scratch && epoch === identityEpoch && request === projectsRequest) await store.enterScratchMode(state.ui.lastScratch) }, /** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */ @@ -674,14 +718,16 @@ export const store = { const request = ++tasksRequest try { const tasks = await api.listTasks(includeArchived) - if (epoch !== identityEpoch || request !== tasksRequest) return + if (epoch !== identityEpoch || request !== tasksRequest || !Array.isArray(tasks)) return set({ tasks }) // 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。 if (!convRestoreDone) { convRestoreDone = true const saved = savedConvId() - if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) { - store.openConversation(saved) + const restored = saved ? tasks.find(t => t.id === saved && !t.archived) : undefined + if (restored && !state.currentConvId) { + if (restored.scratch) await store.openTask(restored.id) + else store.openConversation(restored.id) } } } catch { /* 列表竞态,静默 */ } @@ -719,22 +765,41 @@ export const store = { } }, + /** 打开真实项目:同时离开无项目对话视图。 */ async openProject(projectId: string) { - if (state.currentProjectId !== projectId) resetProjectScope() - set({ currentProjectId: projectId }) - if (store.getState().inboxScope === 'project') { - store.loadProjectCards() - store.connectProject() - } - store.setUI({ lastProjectId: projectId }) - await store.refreshArtifacts(projectId) + setScratchMode(false) + await bindProject(projectId, true) + }, + + /** 进入无项目对话视图:当前会话不是无项目对话时清空会话视图,并打开最近一条未归档的无项目对话。 */ + async enterScratchMode(remember = true) { + const epoch = identityEpoch + const current = state.tasks.find(t => t.id === state.currentConvId) + setScratchMode(true, remember) + if (current?.scratch) return + if (state.currentProjectId || state.currentConvId) resetProjectScope() + set({ currentProjectId: null }) + const latest = latestScratchTask() + if (latest && epoch === identityEpoch) await store.openTask(latest.id) }, /** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。 - * 没有项目时不代建「默认项目」:列表未到达或无创建能力时自动建项目会误触权限与同名冲突。 */ + * 无项目对话视图、或项目列表已到达且没有真实项目时,新建无项目对话;从不代建真实项目。 */ async createTask(projectId?: string) { const epoch = identityEpoch + const changed = () => { if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') } try { + if (state.scratchMode || (!projectId && !state.currentProjectId && state.projectsLoaded && state.projects.length === 0)) { + const { conversation } = await api.createScratchSession() + changed() + setScratchMode(true) + await store.refreshTasks() + changed() + if (!state.tasks.some(t => t.id === conversation.id)) set({ tasks: [{ ...conversation, scratch: true }, ...state.tasks] }) + await store.openTask(conversation.id) + changed() + return + } let pid = projectId ?? state.currentProjectId if (!pid && state.projects.length > 0) pid = state.projects[0].id if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me))) @@ -761,12 +826,15 @@ export const store = { if (title) store.toast('已在当前项目创建任务') }, - /** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */ + /** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确);视图随任务进入或离开无项目对话。 */ async openTask(convId: string) { const epoch = identityEpoch const t = state.tasks.find(x => x.id === convId) - if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId) + if (t?.scratch) { + if (t.projectId !== state.currentProjectId) await bindProject(t.projectId, false) + } else if (t && t.projectId !== state.currentProjectId) await store.openProject(t.projectId) if (epoch !== identityEpoch) return + if (t) setScratchMode(!!t.scratch) await store.openConversation(convId) }, diff --git a/test/scratch-session.test.ts b/test/scratch-session.test.ts new file mode 100644 index 0000000..f14e872 --- /dev/null +++ b/test/scratch-session.test.ts @@ -0,0 +1,129 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { auth } from '../src/auth' +import { canCreateProject, store } from '../src/store' +import type { Conversation, Project } from '../src/contracts' + +const project = (id: string): Project => ({ id, name: id, createdAt: '', updatedAt: '' }) +const task = (id: string, projectId: string, scratch = false, updatedAt = ''): Conversation => + ({ id, projectId, title: id, createdAt: '', updatedAt, ...(scratch ? { scratch: true } : {}) }) +const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status }) +const detail = (conversation: Conversation) => response({ conversation, messages: [], runs: [], activeHitl: [], lastSeq: 0 }) + +let projects: Project[] +let tasks: Conversation[] +let me: Record +let requests: { path: string; init?: RequestInit }[] +let scratchCount: number + +const posts = () => requests.filter(r => r.init?.method === 'POST').map(r => r.path) + +beforeEach(async () => { + projects = []; tasks = []; requests = []; scratchCount = 0 + me = { owner: 'bob', name: 'Bob', auth: true, systemActions: [] } + const saved = new Map() + vi.stubGlobal('localStorage', { getItem: (key: string) => saved.get(key) ?? null, + setItem: (key: string, value: string) => { saved.set(key, value) }, + removeItem: (key: string) => { saved.delete(key) } }) + vi.stubGlobal('fetch', async (path: string, init?: RequestInit) => { + requests.push({ path, init }) + if (path === '/api/projects' && !init?.method) return response(projects) + if (path === '/api/conversations' && !init?.method) return response(tasks) + if (path === '/api/me') return response(me) + if (path === '/api/scratch-sessions' && init?.method === 'POST') { + const id = `scratch-${++scratchCount}` + const conversation = task(`${id}-conv`, id, true) + tasks = [conversation, ...tasks] + return response({ project: project(id), conversation }) + } + const created = path.match(/^\/api\/projects\/([^/]+)\/conversations$/) + if (created && init?.method === 'POST') { + const conversation = task(`${created[1]}-new`, created[1]) + tasks = [conversation, ...tasks] + return response(conversation) + } + const conv = path.match(/^\/api\/conversations\/([^/]+)$/) + if (conv && !init?.method) { + const found = tasks.find(t => t.id === conv[1]) + return found ? detail(found) : response({ error: 'not found' }, 404) + } + if (path.endsWith('/plan') || path.endsWith('/todos')) return response(null) + if (path.endsWith('/artifacts') || path.endsWith('/surfaces')) return response([]) + if (path.endsWith('/cards')) return response({ cards: [] }) + return response({}) + }) + vi.stubGlobal('EventSource', class { addEventListener() {} close() {} }) + auth.beginSession() + await store.refreshMe() +}) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +describe('scratch session entry', () => { + it('enters scratch mode for an account without project creation and no projects, and never creates a project', async () => { + await store.refreshProjects() + expect(store.getState()).toMatchObject({ scratchMode: true, projectsLoaded: true, currentProjectId: null }) + requests = [] + await store.createTask() + expect(posts()).toEqual(['/api/scratch-sessions']) + expect(requests.some(r => r.path === '/api/projects' && r.init?.method === 'POST')).toBe(false) + expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-1-conv', currentProjectId: 'scratch-1', projects: [] }) + }) + + it('always creates a scratch session in scratch mode even when real projects exist', async () => { + projects = [project('host-a')] + await store.refreshProjects() + expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' }) + await store.enterScratchMode() + expect(store.getState()).toMatchObject({ scratchMode: true, currentProjectId: null, currentConvId: null }) + requests = [] + await store.createTask() + await store.createTask() + expect(posts()).toEqual(['/api/scratch-sessions', '/api/scratch-sessions']) + expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-2-conv', currentProjectId: 'scratch-2' }) + }) + + it('keeps creating project conversations in project mode', async () => { + projects = [project('host-a')] + await store.refreshProjects() + requests = [] + await store.createTask() + expect(posts()).toEqual(['/api/projects/host-a/conversations']) + expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'host-a-new', currentProjectId: 'host-a' }) + }) + + it('returns to the remembered scratch view on reload and opens the latest scratch task', async () => { + projects = [project('host-a')] + tasks = [task('old', 'scratch-o', true, '2026-01-01'), task('new', 'scratch-n', true, '2026-02-01'), task('work', 'host-a')] + await store.refreshProjects() + await store.enterScratchMode() + expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' }) + expect(store.getState().ui.lastScratch).toBe(true) + await store.refreshProjects() + expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'new', currentProjectId: 'scratch-n' }) + await store.openProject('host-a') + expect(store.getState()).toMatchObject({ scratchMode: false, currentProjectId: 'host-a' }) + expect(store.getState().ui.lastScratch).toBe(false) + }) +}) + +describe('scratch mode follows the opened task', () => { + it('sets scratch mode for a scratch task and clears it for a normal task', async () => { + projects = [project('host-a')] + tasks = [task('scratch-conv', 'scratch-1', true), task('work', 'host-a')] + await store.refreshProjects() + await store.openTask('scratch-conv') + expect(store.getState()).toMatchObject({ scratchMode: true, currentConvId: 'scratch-conv', currentProjectId: 'scratch-1' }) + expect(store.getState().ui.lastProjectId).toBe('host-a') + await store.openTask('work') + expect(store.getState()).toMatchObject({ scratchMode: false, currentConvId: 'work', currentProjectId: 'host-a' }) + }) +}) + +describe('canCreateProject', () => { + it('allows project creation without auth or with project.create only', () => { + expect(canCreateProject({ auth: false })).toBe(true) + expect(canCreateProject({ auth: true, systemActions: ['project.create'] })).toBe(true) + expect(canCreateProject({ auth: true, systemActions: [] })).toBe(false) + expect(canCreateProject({ auth: true })).toBe(false) + expect(canCreateProject(null)).toBe(false) + }) +}) 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') + } + }) +})