Compare commits

..
Author SHA1 Message Date
weichenandCursor b02ff83dfc Hide project-only surfaces in scratch conversations
governance / governance (pull_request) Successful in 14s
ci / test-and-build (pull_request) Successful in 39s
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>
2026-10-22 03:48:00 +00:00
weichenandCursor 1d7f2a3e1b Add the no-project conversation view backed by scratch sessions
governance / governance (pull_request) Successful in 18s
ci / test-and-build (pull_request) Successful in 3m48s
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 <cursoragent@cursor.com>
2026-10-22 03:18:08 +00:00
weichen 18fc958def Merge pull request 'Skip the project surfaces request when no project is current' (#122) from fix/no-project-surfaces-404 into main
governance / governance (push) Successful in 15s
ci / test-and-build (push) Successful in 32s
2026-10-20 15:39:58 +00:00
agent-exec-transport 16a059381c agent-exec transport ce569400d5a47d09bea58e39b89b8843/8043305a983f
governance / governance (pull_request) Successful in 23s
ci / test-and-build (pull_request) Successful in 31s
2026-10-20 15:38:38 +00:00
weichen e4cd8f1a57 Merge pull request 'Tell removed members why sending is refused' (#121) from fix/multiuser-collab-followups into main
governance / governance (push) Successful in 17s
ci / test-and-build (push) Successful in 48s
2026-10-20 11:58:24 +00:00
agent-exec-transport 9f7f4a164b agent-exec transport 257898d9fdadf655ee20accee6813725/4e997a3ec7a5
governance / governance (pull_request) Successful in 29s
ci / test-and-build (pull_request) Successful in 1m11s
2026-10-20 11:54:59 +00:00
weichen a13d041159 Merge pull request 'Unexport two helpers to keep the knip ratchet green' (#120) from fix/multiuser-project-collab-r4 into main
governance / governance (push) Successful in 19s
ci / test-and-build (push) Successful in 25s
2026-10-20 11:19:39 +00:00
agent-exec-transport f6442cc501 agent-exec transport b00819fbf97351256c6953a271c02e52/a305d1ebb539
ci / test-and-build (pull_request) Successful in 32s
governance / governance (pull_request) Successful in 13s
2026-10-20 11:12:36 +00:00
weichen e2a670e2aa Merge pull request 'Keep admin user detail and one-time password working' (#119) from fix/multiuser-project-collab-r3 into main
governance / governance (push) Successful in 20s
ci / test-and-build (push) Successful in 29s
2026-10-20 10:50:08 +00:00
agent-exec-transport 1fe321240a agent-exec transport 91fa500d0ed8f7daca02bd46ba843a9a/b201c6aced99
governance / governance (pull_request) Successful in 13s
ci / test-and-build (pull_request) Successful in 46s
2026-10-20 10:48:43 +00:00
weichen c6df2dc180 Merge pull request 'Skip role options for non-managers and guard zero run time' (#117) from fix/multiuser-project-collab-r2 into main
ci / test-and-build (push) Successful in 5m56s
governance / governance (push) Successful in 20s
2026-10-20 07:05:50 +00:00
weichen 97b947a8e3 Merge branch 'main' into fix/multiuser-project-collab-r2
ci / test-and-build (pull_request) Successful in 1m13s
governance / governance (pull_request) Successful in 16s
2026-10-20 07:01:35 +00:00
weichen 096947f9a2 Merge pull request 'IAM U5-Frontend integration-tests via PI direct (cursor 3/3 blocked)' (#118) from feature/iam-link-u4-frontend-auto-discover into main
governance / governance (push) Successful in 19s
ci / test-and-build (push) Failing after 56s
2026-10-20 06:59:46 +00:00
agent-exec-transport d997d4a4d0 agent-exec transport 85c00f1e2274be1a84b73a421987fc69/ba12fd06dc72
governance / governance (pull_request) Successful in 16s
ci / test-and-build (pull_request) Successful in 34m20s
2026-10-20 06:21:55 +00:00
20 changed files with 901 additions and 67 deletions
+6 -2
View File
@@ -25,7 +25,9 @@ export interface UserRoleBinding {
bindingId: string; userId: string; roleId: string; active: boolean; bindingVersion: number
assignedBy: string; assignedAt: string; userStateVersion: number; authorityEpoch: number
}
export interface UserRoleView { binding?: UserRoleBinding; role?: RoleRecord }
/** getUserRole 返回的角色摘要:实测不含 permissions,权限补齐由角色列表条目提供。 */
export interface UserRoleSummary extends Omit<RoleRecord, 'permissions'> { permissions?: RolePermissionRecord[] }
export interface UserRoleView { binding?: UserRoleBinding; role?: UserRoleSummary }
/** Backend 计算的本人 project.create 联动(/api/me linkedCapabilities)。 */
export interface LinkedCapabilities {
projectCreate: boolean; source?: string; linkedActions: string[]; conflicts?: string[]; authorityEpoch: number
@@ -115,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<Project[]>(r)),
createProject: (name: string) => auth.fetch(`${BASE}/api/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }).then(r => json<Project>(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<Project>(r)),
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
@@ -153,7 +156,8 @@ export const api = {
// 管理员建号:可提供初始密码;省略密码时由服务端生成并仅返回一次。
// isAdmin 仅超级管理员可用(R3/R4),普通管理员携带时由服务端拒绝。
createAdminUser: (owner: string, name: string, password?: string, isAdmin?: boolean) => auth.fetch(`${BASE}/api/admin/users`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ owner, profile: { name }, ...(password ? { password } : {}), ...(isAdmin ? { isAdmin: true } : {}) }) }).then(r => json<{ userId: string; owner: string; passwordSet: boolean; mustChangePassword: boolean; initializationState: string; initialPassword?: string }>(r)),
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST' }).then(r => json<AdminIssuedOneTimePassword>(r)),
// 服务端严格解码 JSON 对象体:无 Content-Type 与请求体的 POST 会被拒为 400 EOF。
adminIssueOneTimePassword: (owner: string) => auth.fetch(`${BASE}/api/admin/users/${encodeURIComponent(owner)}/one-time-password`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }).then(r => json<AdminIssuedOneTimePassword>(r)),
searchAdminUsers: (request: { query: string; mode: 'exact' | 'fuzzy'; status: 'all' | 'enabled' | 'disabled'; fields: string[]; cursor?: string; limit?: number }) => {
const params = new URLSearchParams({ query: request.query, mode: request.mode, status: request.status,
fields: request.fields.join(','), limit: String(request.limit ?? 20) })
+9 -2
View File
@@ -95,13 +95,20 @@ export function GrantChangePreview({ baseline, grants, labels, owner }: {
/** 账号的创建能力来源与联动说明(角色默认与明确授权可并存)。 */
export function ProjectCreateSourceNote({ role, grants }: {
role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined; grants: string[]
}) {
const sources = projectCreateSources(role, grants)
if (sources.length === 0) return <p className="ad-hint">未获得创建项目能力:被加入项目后由成员关系提供基础协作</p>
return <p className="ad-hint">创建项目来源:{sources.join(' + ')} · 联动形成本人创建项目的设置、成员、项目权限与文档职责管理资格</p>
}
/** getUserRole 的角色摘要不带 permissions:用已加载角色列表里同一 roleId 的条目补齐,
* 列表里也没有时按无角色默认权限处理(不新增请求,也不改写已有权限)。 */
export function roleWithPermissions(role: UserRoleView['role'], roles: RoleRecord[]): UserRoleView['role'] {
if (!role || role.permissions) return role
return roles.find(item => item.roleId === role.roleId) ?? role
}
export function AccountUsersPanel() {
const me = useStore(state => state.me)
const allowed = (action: string) => !!me?.isAdmin || hasSystemAction(me?.systemActions, action)
@@ -326,7 +333,7 @@ export function AccountUsersPanel() {
<button className="ad-mini" disabled={roleLoading || !roleTrail.length} onClick={() => { setRoleCursor(roleTrail[roleTrail.length - 1]); setRoleTrail(roleTrail.slice(0, -1)) }}>上一页角色</button>
<button className="ad-mini" disabled={roleLoading || !nextRoleCursor} onClick={() => { setRoleTrail([...roleTrail, roleCursor]); setRoleCursor(nextRoleCursor) }}>下一页角色</button>
</div>}
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleView?.role} grants={grantBaseline} />}
{(canViewRoles || canViewGrants) && <ProjectCreateSourceNote role={roleWithPermissions(roleView?.role, roles)} grants={grantBaseline} />}
{canViewGrants && <Collapsible id={`system-grants-${selected.userId}`} trigger="明确系统权限">
<GrantGroupEditor catalog={catalog} grants={grants} disabled={!canManageGrants || !policy.maintain || busy} onChange={setGrants} />
<GrantChangePreview baseline={grantBaseline} grants={grants} labels={labels} owner={selected.owner} />
+2 -2
View File
@@ -637,14 +637,14 @@ function ConversationSession() {
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
<div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
{!conversation?.scratch && <div className="companion-setting"><span>读取同项目的其他对话</span><button className="btn ghost"
title={conversation?.allowCrossRead === false
? '本会话不读取其他会话记录(点击开启)'
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
</button>
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>
<span className="companion-setting-note">实际读取范围还需项目设置允许</span></div>}
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
</Collapsible>
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
+2 -1
View File
@@ -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) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
+28 -15
View File
@@ -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,
<button className="tsb-row-act" title={t.archived ? '恢复' : '归档'} onClick={e => { e.stopPropagation(); store.archiveTask(t.id, !t.archived) }}>
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
</button>
<button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
{!t.scratch && <button className={`tsb-row-act ${t.crossPrivate ? 'active' : ''}`} title={t.crossPrivate ? '私有会话:不被其他会话读取(点击取消)' : '设为私有(不被其他会话读取)'}
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
<Icon name="shield" size={13} />
</button>
</button>}
</span>
)
@@ -121,7 +124,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
<Icon name="message" size={14} />
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{t.crossPrivate && !t.scratch && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
</button>
{rowActions(t)}
@@ -136,9 +139,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<div className="tsb-brand">
<button className="tsb-projrow" title="切换项目"
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
<Icon name="folder" size={15} />
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
{stage && <span className="tsb-projrow-stage">{stage}</span>}
<Icon name={scratchMode ? 'message' : 'folder'} size={15} />
<span className="tsb-projrow-name">{scratchMode ? '无项目对话' : currentProject?.name ?? '选择项目'}</span>
{stage && !scratchMode && <span className="tsb-projrow-stage">{stage}</span>}
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></button>
@@ -147,7 +150,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 主入口创建会话;项目创建保留在次级菜单。 */}
<div className="tsb-actions">
<div className="tsb-newtask">
<button className="tsb-newtask-main" disabled={noProjects} title={noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
<button className="tsb-newtask-main" disabled={noProjects && !scratchMode}
title={scratchMode ? '新建无项目对话(⌘N)' : noProjects ? emptyProjectsNotice(projectsLoaded, canCreate) : '在当前项目新建对话(⌘N)'}
onClick={() => createConversation()}>
<Icon name="plus" size={15} />新建对话
</button>
@@ -156,12 +160,12 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
</div>
<div className="tsb-search">
<Icon name="search" size={14} />
<input aria-label="搜索本项目会话" placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
<input aria-label={scratchMode ? '搜索无项目对话' : '搜索本项目会话'} placeholder="搜索对话…" value={q} onChange={e => setQ(e.target.value)} />
</div>
</div>
<div className="tsb-orgrow">
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</span>
<span className="tsb-seclabel" style={{ padding: 0 }}>{scratchMode ? '无项目对话' : '本项目会话'}</span>
<span style={{ flex: 1 }} />
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
@@ -195,7 +199,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
)}
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">{noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
<div className="tsb-empty">{scratchMode ? '开始一个通用对话;无项目对话彼此独立,不会使用项目文档与知识。'
: noProjects ? emptyProjectsNotice(projectsLoaded, canCreate)
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
)}
{rest.map(t => taskRow(t))}
@@ -215,7 +220,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
<kbd>{entry.shortcut}</kbd>
</button>
))}
{currentProject && (
{currentProject && !scratchMode && (
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
<span className="tsb-tool-name">项目设置</span>
@@ -239,6 +244,10 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{/* 弹出菜单们 */}
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
<MenuItem icon="message" onClick={() => { void store.enterScratchMode(); onConversationOpen?.(); setMenu(null) }}>
无项目对话
<kbd>{scratchMode ? '当前 · ' : ''}{scratchTasks.filter(t => !t.archived).length} 会话</kbd>
</MenuItem>
{projects.map(p => {
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
@@ -246,7 +255,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
return (
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
{p.name}
<kbd>{p.id === currentProjectId ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
<kbd>{p.id === currentProjectId && !scratchMode ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
</MenuItem>
)
})}
@@ -258,12 +267,14 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
</Menu>
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
{!noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
{scratchMode
? <MenuItem icon="message" onClick={() => { createConversation(); setMenu(null) }}>新建无项目对话<kbd>⌘N</kbd></MenuItem>
: !noProjects && <MenuItem icon="plus" onClick={() => { createConversation(); setMenu(null) }}>新建对话 · 当前项目<kbd>⌘N</kbd></MenuItem>}
{canCreate && <>
<div className="tsb-menu-div" />
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
</>}
{noProjects && !canCreate && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{noProjects && !canCreate && !scratchMode && <div className="tsb-tool-note">{emptyProjectsNotice(projectsLoaded, canCreate)}</div>}
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
))}
@@ -273,6 +284,8 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
{rowTask && <>
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
<MenuItem icon={rowTask.archived ? 'undo' : 'archive'} onClick={() => { store.archiveTask(rowTask.id, !rowTask.archived); setMenu(null) }}>{rowTask.archived ? '恢复(移出归档)' : '归档'}</MenuItem>
</>}
{rowTask && !rowTask.scratch && <>
<div className="tsb-menu-div" />
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
+12 -8
View File
@@ -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() {
</div>
{rightTab === 'files'
? <FilesTab />
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} scratch={scratch} />}
</>
)
}
@@ -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<typeof CurrentTaskCard>[0]['plan']
todos: Parameters<typeof CurrentTaskCard>[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 ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
{!scratch && <button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</button>}
<span style={{ flex: 1 }} />
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
: <InboxList cards={cards} docBridge={docBridge} callbacks={scratch ? scratchInboxCallbacks : inboxCallbacks} filter={filter} />}
<WorkspaceSurfaces />
</>}
</div>
+7 -1
View File
@@ -78,12 +78,18 @@ export function RdmMembersSection({ project }: { project: Project }) {
setPicked([]); setPickedItems([]); knownPicks.current.clear()
setCandRoleId(''); setCandMatch('fuzzy'); setCandFields('both'); setMemberQuery(''); setMemberRoleId('')
setRoleOptions(null)
}, [project])
// rdm.role.options 受 member.manage 授权;未确认前先不发请求,避免普通成员被 403。
// 跟着 [project, manage.canManage] 重订:项目切换或权限放开后重发,迟到响应丢弃。
useEffect(() => {
if (!manage.canManage) return
let live = true
cmd<{ roles?: RdmProfRole[] }>('rdm.role.options', roleOptionsPayload(projectId))
.then(out => { if (live) setRoleOptions(out.roles ?? []) })
.catch(() => { if (live) setRoleOptions(null) })
return () => { live = false }
}, [project])
}, [project, manage.canManage])
useEffect(() => {
setManageActions(null)
+2
View File
@@ -18,6 +18,8 @@ export interface Conversation {
allowCrossRead?: boolean; crossPrivate?: boolean;
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
sessionNote?: string;
/** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */
scratch?: boolean;
createdAt: string; updatedAt: string;
}
+2 -2
View File
@@ -62,7 +62,7 @@ const CAPABILITY_SOURCE_LABELS: Record<string, string> = {
role: '职位角色', 'system-grant': '明确系统授权', 'bootstrap-admin': '初始管理员',
}
export function capabilitySourceLabel(source: string | undefined): string {
function capabilitySourceLabel(source: string | undefined): string {
return (source && CAPABILITY_SOURCE_LABELS[source]) || source || '未知来源'
}
@@ -89,7 +89,7 @@ export function noProjectError(loaded: boolean, canCreate: boolean): string {
return '尚无可用项目,请等待项目负责人邀请你加入'
}
export interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
interface MemberDuty { kind: 'creator' | 'owner' | 'member'; label: string; duties: string; source: string }
/** 项目内实际职责:成员管理只随不可变创建者;负责人只是界面身份。 */
export function memberDuty(member: { owner: string; role: string }, creator: string | undefined): MemberDuty {
+4 -3
View File
@@ -205,12 +205,13 @@ export function rebaseGrantDraft(baseline: string[], local: string[], server: st
return [...next].sort()
}
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。 */
/** 某账号 project.create 的实际来源(角色默认与明确系统授权可并存)。
* getUserRole 的角色摘要不带 permissions,缺失按「无角色默认权限」处理。 */
export function projectCreateSources(
role: { name: string; permissions: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
role: { name: string; permissions?: { actionId: string; allowed: boolean }[] } | null | undefined, grants: string[],
): string[] {
const sources: string[] = []
if (role?.permissions.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
if (role?.permissions?.some(item => item.actionId === PROJECT_CREATE_ACTION && item.allowed)) sources.push(`职位角色「${role.name}」`)
if (grants.includes(PROJECT_CREATE_ACTION)) sources.push('明确系统授权')
return sources
}
+20 -4
View File
@@ -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 () => {
+103 -26
View File
@@ -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<Me, 'auth' | 'systemActions'> | 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<string, boolean> = {}
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<Conversation | undefined>((latest, t) => !latest || t.updatedAt > latest.updatedAt ? t : latest, undefined)
}
function observeDetail(observations: Record<string, DetailObservation>, 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)
},
@@ -817,7 +885,7 @@ export const store = {
}
// U4/R5:已保存页面从权威快照重载(刷新/断线恢复;事件流只做增量投影)。
// surfaceList 返回扁平 SurfaceMeta,不可写入 surfaces;必须经 surfaceGet 水合为 Snapshot。
api.projectSurfaces(state.currentProjectId ?? '').then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
if (state.currentProjectId) api.projectSurfaces(state.currentProjectId).then(list => { if (state.currentConvId === convId && conversationEpoch === epoch) set({ projectSurfaces: list ?? [] }) }).catch(() => {})
api.surfaceList(convId).then(async list => {
if (state.currentConvId !== convId || conversationEpoch !== epoch) return
const metas = list ?? []
@@ -1145,10 +1213,12 @@ export const store = {
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds, attachmentNames: [], ...(taskRef ? { taskRef } : {}) }) })
return 'busy'
}
store.toast(`发送失败:${e}`)
const removed = isNotProjectMember(e)
store.toast(removed ? '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看' : `发送失败:${e}`)
set({ outbox: enqueueMessageFront(state.outbox, { id, text, attachmentIds,
attachmentNames: attachmentIds.map(attachmentId => state.artifacts.find(artifact => artifact.id === attachmentId)?.name ?? '已添加材料'),
...(taskRef ? { taskRef } : {}), deliveryError: '发送未成功,内容已保留;可修改后重试或移除' }) })
...(taskRef ? { taskRef } : {}),
deliveryError: removed ? '无法发送:你已不在该项目中或项目已关闭,内容已保留' : '发送未成功,内容已保留;可修改后重试或移除' }) })
await store.reconcile()
return 'rejected'
} finally {
@@ -1720,6 +1790,11 @@ function isConversationBusy(err: unknown): boolean {
return String(err).includes('409:')
}
/** 已被移出项目或项目已关闭:后端以 403 `conversation: not-project-member` 拒绝继续发送。 */
function isNotProjectMember(err: unknown): boolean {
return String(err).includes('not-project-member')
}
function shellPhase() {
const liveRunId = state.live?.runId ?? null
return deriveShellPhase({
@@ -1744,5 +1819,7 @@ export function mergeStartedRun(runs: Run[], run: Run): Run[] {
export function eventTime(e: { ts?: string }, now = Date.now()): number {
if (!e.ts) return now
const t = Date.parse(e.ts)
return Number.isNaN(t) ? now : t
// 后端 0 值占位(0001-01-01T00:00:00Z 等)会让状态栏读到巨大耗时,回落 now 让「已工作」保持真实时钟。
if (Number.isNaN(t) || t <= 0) return now
return t
}
+61
View File
@@ -0,0 +1,61 @@
// 管理端用户面板两处缺陷的回归锁定:
// (1) getUserRole 的 role 摘要不带 permissions(线上实测),projectCreateSources 不得因此抛异常;
// 展示来源时用已加载角色列表里同一 roleId 的条目补齐权限,不新增请求。
// (2) 一次性登录密码按服务端严格解码要求发送 JSON 对象体。
import { afterEach, describe, expect, it, vi } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { api, type RoleRecord } from '../src/api'
import { auth } from '../src/auth'
import { ProjectCreateSourceNote, roleWithPermissions } from '../src/components/AccountUsersPanel'
import { projectCreateSources } from '../src/rolePermissions'
const listed: RoleRecord = {
roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1,
permissions: [
{ actionId: 'project.create', allowed: true, capAllowed: true, permissionVersion: 2 },
{ actionId: 'document.read', allowed: true, capAllowed: true, permissionVersion: 1 },
],
}
/** getUserRole 的真实响应形状:有角色字段、没有 permissions。 */
const summary = { roleId: 'role-pm', name: '项目经理', description: '研发', enabled: true, roleVersion: 3, bindingCount: 1 }
afterEach(() => vi.restoreAllMocks())
describe('角色摘要缺少 permissions 时的创建能力来源', () => {
it('把缺失的 permissions 当作空集,不抛异常也不虚构角色来源', () => {
expect(projectCreateSources({ name: '项目经理' }, [])).toEqual([])
expect(projectCreateSources({ name: '项目经理' }, ['project.create'])).toEqual(['明确系统授权'])
expect(projectCreateSources({ ...summary, permissions: undefined }, [])).toEqual([])
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={{ name: '项目经理' }} grants={[]} />))
.toContain('被加入项目后由成员关系提供基础协作')
})
it('用角色列表里同一 roleId 的权限补齐来源', () => {
const resolved = roleWithPermissions(summary, [listed])
expect(resolved?.permissions?.some(item => item.actionId === 'project.create')).toBe(true)
expect(renderToStaticMarkup(<ProjectCreateSourceNote role={resolved} grants={[]} />))
.toContain('创建项目来源:职位角色「项目经理」')
})
it('列表里没有对应条目时按无角色默认权限处理,角色自带权限时不被列表覆盖', () => {
expect(projectCreateSources(roleWithPermissions(summary, []), [])).toEqual([])
const denied = { ...listed, roleId: 'role-other', permissions: [{ actionId: 'project.create', allowed: false, capAllowed: false, permissionVersion: 1 }] }
expect(projectCreateSources(roleWithPermissions({ ...summary, permissions: denied.permissions }, [denied]), [])).toEqual([])
})
})
describe('管理员一次性登录密码请求体', () => {
it('发送 POST + JSON 对象体,避免服务端 400 EOF', async () => {
const issued = { owner: 'alice', id: 'otp_123', oneTimePassword: 'otp-0001', mustChangePassword: true }
const request = vi.spyOn(auth, 'fetch').mockResolvedValue(new Response(JSON.stringify(issued)))
const out = await api.adminIssueOneTimePassword('alice')
const [url, options] = request.mock.calls[0]
expect(url).toBe('/api/admin/users/alice/one-time-password')
expect(options?.method).toBe('POST')
expect(options?.headers).toEqual({ 'Content-Type': 'application/json' })
expect(JSON.parse(String(options?.body))).toEqual({})
expect(out).toEqual(issued)
})
})
+33
View File
@@ -0,0 +1,33 @@
// eventTime 取事件上的服务端时间;后端 0 值占位(0001-01-01T00:00:00Z)
// 不能让状态栏把「已工作」读成几百年的耗时——非正 epoch 一律回落到墙上时钟。
import { describe, expect, it } from 'vitest'
import { eventTime } from '../src/store'
describe('eventTime', () => {
it('returns now when the timestamp is missing', () => {
const now = 1_700_000_000_000
expect(eventTime({}, now)).toBe(now)
expect(eventTime({ ts: '' }, now)).toBe(now)
expect(eventTime({ ts: undefined }, now)).toBe(now)
})
it('returns now for unparseable timestamps', () => {
const now = 1_700_000_000_000
expect(eventTime({ ts: 'not-a-date' }, now)).toBe(now)
expect(eventTime({ ts: '2026-13-40T99:99:99Z' }, now)).toBe(now)
})
it('returns now when the parsed time is not a positive epoch (zero-value placeholder)', () => {
// 0001-01-01T00:00:00Z is the Go/Unix zero time;过去不能解释为有效事件时间。
const now = 1_700_000_000_000
expect(eventTime({ ts: '0001-01-01T00:00:00Z' }, now)).toBe(now)
// Date.parse('1970-01-01T00:00:00Z') === 0,应被一并回落,否则状态栏会显示从 epoch 至今。
expect(eventTime({ ts: '1970-01-01T00:00:00Z' }, now)).toBe(now)
})
it('returns the parsed epoch for a real, parseable timestamp', () => {
const now = 1_700_000_000_000
expect(eventTime({ ts: '2024-01-02T03:04:05Z' }, now)).toBe(Date.parse('2024-01-02T03:04:05Z'))
expect(eventTime({ ts: '2026-10-12T10:00:00Z' }, now)).toBe(Date.parse('2026-10-12T10:00:00Z'))
})
})
+4 -1
View File
@@ -21,7 +21,10 @@ describe('管理员一次性密码发放', () => {
const out = await api.adminIssueOneTimePassword('alice')
expect(fetchSpy).toHaveBeenCalledTimes(1)
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', { method: 'POST' })
// 服务端严格解码 JSON 对象体:无体无 Content-Type 会被拒为 400 EOF。
expect(fetchSpy).toHaveBeenCalledWith('/api/admin/users/alice/one-time-password', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
})
expect(out).toEqual(response)
})
+64
View File
@@ -0,0 +1,64 @@
// ps-frontend-no-project-surfaces-r2:openConversation 在 state.currentProjectId 为空时
// 不再触发 /api/projects/<id>/surfaces 请求(避免被移除项目成员打开历史会话时的 404 噪音)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { Conversation, Message, SurfaceMeta } from '../src/contracts'
function detail(id: string) {
return {
conversation: { id, projectId: '', title: id, createdAt: '', updatedAt: '' } as Conversation,
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
}
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
let projectSurfacesSpy: ReturnType<typeof vi.spyOn>
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
projectSurfacesSpy = vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
// 还原至 currentProjectId = null 的干净投影,避免别的套件残留影响本次断言。
store.resetIdentity()
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('openConversation projectSurfaces gating', () => {
it('skips the /api/projects/.../surfaces request and keeps projectSurfaces empty when no project is current', async () => {
expect(store.getState().currentProjectId).toBeNull()
await store.openConversation('cnv_no_project')
// 让微任务(包含 surfaceList/Stream 等链)落定,但不改变当前结论。
await new Promise(r => setTimeout(r, 0))
expect(projectSurfacesSpy).not.toHaveBeenCalled()
expect(store.getState().projectSurfaces).toEqual([])
expect(store.getState().currentProjectId).toBeNull()
})
it('still requests /api/projects/<id>/surfaces when a project is current', async () => {
const metas: SurfaceMeta[] = [{ surfaceId: 'surf_1', title: '页面一', pluginId: 'plg_1', revision: 1, updatedAt: '2026-10-12T00:00:00Z' }]
projectSurfacesSpy.mockResolvedValueOnce(metas)
await store.openProject('prj_with_surfaces')
expect(store.getState().currentProjectId).toBe('prj_with_surfaces')
projectSurfacesSpy.mockClear()
await store.openConversation('cnv_with_project')
expect(projectSurfacesSpy).toHaveBeenCalledTimes(1)
expect(projectSurfacesSpy.mock.calls[0][0]).toBe('prj_with_surfaces')
// 微任务处理完 .then 后,projectSurfaces 投影被填进服务端的元数据列表。
await new Promise(r => setTimeout(r, 0))
expect(store.getState().projectSurfaces).toEqual(metas)
})
})
+264
View File
@@ -0,0 +1,264 @@
// RdmMembersSection rdm.role.options 授权门控:rdm.perm.check 给出 member.manage
// 未允许时,不得提前发 rdm.role.options(普通成员被 403);允许后必须发。
//
// 测试在 node 环境下用最小化的 DOM mock 驱动 React 19 createRoot,让 useEffect 正常落槽;
// 不去依赖 happy-dom/jsdom(受网络/包锁约束,allowed_paths 也限制仅 src + test)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement, type ReactNode } from 'react'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { RdmMembersSection } from '../src/components/adminRdmSections'
import { api } from '../src/api'
import type { Project } from '../src/contracts'
// 用 vi.mock 隔离 useStore:组件只用到 select(state).me,其它 store 字段触发不到,
// 不会影响此处关注点(避免 host Store 启动监听器、送 SSE 等副作用污染测试)。
vi.mock('../src/store', () => {
const state = {
me: { owner: 'bob', name: 'bob', auth: true },
}
return {
useStore: (select: (value: typeof state) => unknown) => select(state),
eventTime: (_e: { ts?: string }, now: number) => now,
}
})
// ---- 自定义 React DOM 桩:让 useEffect 在 node 环境落到 commit 阶段 ----
// 服务端 React 不运行 useEffect;createRoot 需要一个 DOM-shaped container。
// 这里只覆盖 react-dom-client 用到的几个关键入口(addEventListener、tagName、namespaceURI、
// ownerDocument.defaultView.HTMLIFrameElement、activeElement、documentElement)。任何
// 新字段缺失都会在第一处 React 调用时显式爆出,便于修复而不是静默吞下。
class HTMLIFrameElement {}
class MockDocument {
nodeType = 9
defaultView: any
HTMLIFrameElement = HTMLIFrameElement
activeElement: any = null
documentElement: any
constructor() {
this.defaultView = makeWindow()
this.defaultView.document = this
this.activeElement = null
this.documentElement = this
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {}
contains() { return false }
createElement(tag: string) {
const el = new MockElement()
el.tagName = tag
el.nodeName = tag
el.ownerDocument = this
if (renderObserver) renderObserver.append(tag)
return el
}
createElementNS(_ns: string, tag: string) { return this.createElement(tag) }
createTextNode() { return new MockElement() }
}
/** 创建 DOM 节点时把它们的 tag 累积下来,替代 innerHTML 读取(Mock 上 innerHTML 为空)。 */
let renderObserver: { tags: string[]; reset(): void; append(t: string): void } | null = null
class MockElement {
nodeType = 1
style: Record<string, string> = {}
attributes: Record<string, string> = {}
tagName = 'DIV'
nodeName = 'DIV'
namespaceURI = 'http://www.w3.org/1999/xhtml'
ownerDocument: any
childNodes: any[] = []
parentNode: any = null
appendChild(c: any) { this.childNodes.push(c); c.parentNode = this; return c }
removeChild(c: any) { const i = this.childNodes.indexOf(c); if (i >= 0) this.childNodes.splice(i, 1); c.parentNode = null; return c }
insertBefore(c: any, ref: any) {
const i = ref ? this.childNodes.indexOf(ref) : this.childNodes.length
this.childNodes.splice(i < 0 ? this.childNodes.length : i, 0, c)
c.parentNode = this
return c
}
contains(c: any) {
for (let n: any = c; n; n = n.parentNode) if (n === this) return true
return false
}
addEventListener() {}
removeEventListener() {}
dispatchEvent() {}
focus() {}
setAttribute(k: string, v: string) { this.attributes[k] = v }
removeAttribute(k: string) { delete this.attributes[k] }
cloneNode() { return new MockElement() }
/** React 写 `<select>` 时会读 `select.options`,给一个可索引的 length 让 updateOptions 不崩。 */
get options() {
return this.childNodes
}
/** React 写入 innerHTML;这里读为空即可(断言用别的字段)。 */
get innerHTML() { return '' }
get outerHTML() { return '' }
get textContent() { return '' }
set textContent(_v: string) { /* 允许 commit 阶段写入 */ }
get value() { return this.attributes.value ?? '' }
set value(v: string) { this.attributes.value = v }
get firstChild() { return this.childNodes[0] }
get lastChild() { return this.childNodes[this.childNodes.length - 1] }
get nextSibling() {
if (!this.parentNode) return null
const i = this.parentNode.childNodes.indexOf(this)
return this.parentNode.childNodes[i + 1] ?? null
}
get previousSibling() {
if (!this.parentNode) return null
const i = this.parentNode.childNodes.indexOf(this)
return this.parentNode.childNodes[i - 1] ?? null
}
}
function makeWindow() {
const w: any = {
event: undefined,
HTMLIFrameElement,
document: undefined as any,
navigator: { userAgent: 'node' },
}
return w
}
function makeContainer(): MockElement {
const doc = new MockDocument()
const el = doc.createElement('div') as MockElement
return el
}
function installDomGlobals() {
const win = makeWindow()
const doc = new MockDocument()
doc.defaultView = win
win.document = doc
;(globalThis as any).window = win
;(globalThis as any).document = doc
;(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true
;(globalThis as any).MessageChannel = class { port1 = { postMessage() {} }; port2 = { onmessage: null as any, postMessage() {} } }
;(globalThis as any).setImmediate = (cb: (...args: any[]) => void) => Promise.resolve().then(() => cb())
}
let _installed = false
function ensureDomGlobals() {
if (_installed) return
installDomGlobals()
_installed = true
}
// ---- 用例辅助 ----
const project: Project = {
id: 'host-1', owner: 'alice', name: '项目P', createdAt: '', updatedAt: '',
settings: { 'rdm.projectId': 'domain-7', 'rdm.state': 'ready' },
}
type CmdHandler = (id: string, params: Record<string, unknown>) => Promise<unknown> | unknown
/** 直接劫持 api.rdmProjectCommand,用 handlers[id] 分别登记每个命令的响应。 */
function spyCommandBus(handlers: Record<string, CmdHandler>) {
const calls: Array<[string, Record<string, unknown>]> = []
const original = api.rdmProjectCommand.bind(api)
const spy = vi.spyOn(api, 'rdmProjectCommand').mockImplementation(async (_proj: Project, id: string, params: Record<string, unknown> = {}) => {
const args: [string, Record<string, unknown>] = [id, params]
calls.push(args)
if (!handlers[id]) throw new Error(`unexpected command: ${id} (no handler registered)`)
return handlers[id](id, params)
})
return {
calls,
roleOptionsCalls: () => calls.filter(([id]) => id === 'rdm.role.options'),
permCheckCalls: () => calls.filter(([id]) => id === 'rdm.perm.check'),
memberListCalls: () => calls.filter(([id]) => id === 'rdm.project.member.list'),
restore: () => {
spy.mockRestore()
;(api as any).rdmProjectCommand = original
},
}
}
let root: Root | null = null
let container: MockElement | null = null
beforeEach(() => {
ensureDomGlobals()
renderObserver = {
tags: [],
reset() { this.tags = [] },
append(t: string) { this.tags.push(t) },
}
})
afterEach(async () => {
if (root) {
await act(async () => root!.unmount())
root = null
}
container = null
renderObserver = null
vi.restoreAllMocks()
})
async function renderAndFlush(element: ReactNode, flushes: number = 3) {
container = makeContainer()
await act(async () => {
root = createRoot(container as any)
root!.render(element)
})
// 反复冲一轮 microtask flush,让 perm.check 的 .then 落进 commit。
for (let i = 0; i < flushes; i++) {
await act(async () => { await Promise.resolve() })
}
}
/** 助记:累计的 render 标签里「候选专业角色筛选」下拉 = 看到标 'select' + 紧邻的 option 角色字符串。 */
function sawCandidateRoleFilter(): boolean {
if (!renderObserver) return false
// React 渲染时按 tag 序列创建 select/option;候选筛选断言只需确保 select 节点创建了。
return renderObserver.tags.includes('select')
}
describe('RdmMembersSection rdm.role.options 授权门控', () => {
it('member.manage 未被允许时不得请求 rdm.role.options(普通成员避免 403)', async () => {
const bus = spyCommandBus({
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: false }] }),
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(bus.permCheckCalls().length).toBe(1)
expect(bus.roleOptionsCalls().length).toBe(0)
})
it('member.manage 被允许后再请求 rdm.role.options,并把结果写回角色筛选', async () => {
const bus = spyCommandBus({
'rdm.perm.check': () => ({ actions: [{ action: 'member.manage', allowed: true }] }),
'rdm.role.options': () => ({ roles: [
{ roleId: 'r-eng', name: '工程师' },
{ roleId: 'r-qa', name: '测试' },
] }),
'rdm.project.member.list': () => ({ members: [], accountDirectoryAvailable: true }),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(bus.permCheckCalls().length).toBe(1)
expect(bus.roleOptionsCalls().length).toBe(1)
})
it('门控生效前的初始挂载阶段不出现专业角色筛选下拉(不提供误导选项)', async () => {
// 用一个永挂起的 perm.check 模拟「自省中」——此时页面上确实只见核对提示,
// 候选区不出现;这是顺带的渲染回归保单,防止门控未来误把 options 提前注入。
// memberManagementView 在 actions===null 时返回 canManage:false,rdm.role.options 因此不发请求;
// 同时 candRoleId 筛选(占位 select)也不应渲染——manage.canManage=false 时整个 ad-create 块被跳掉。
spyCommandBus({
'rdm.perm.check': () => new Promise<{ actions: { action: string; allowed: boolean }[] }>(() => {}),
'rdm.project.member.list': () => new Promise<any>(() => {}),
})
await renderAndFlush(createElement(RdmMembersSection, { project }))
expect(sawCandidateRoleFilter()).toBe(false)
})
})
+129
View File
@@ -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<string, unknown>
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<string, string>()
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)
})
})
+92
View File
@@ -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<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')
}
})
})
+57
View File
@@ -0,0 +1,57 @@
// 被移出项目/项目关闭后的发送拒绝:403 `conversation: not-project-member` 走专用文案,
// 其他失败保持通用文案(见 src/store.ts sendMessage 的 catch 分支)。
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { api } from '../src/api'
import { store } from '../src/store'
import type { Conversation, Message } from '../src/contracts'
const REMOVED_TOAST = '无法继续发送:你已不在该项目中或项目已关闭;历史对话仍可查看'
const REMOVED_DELIVERY_ERROR = '无法发送:你已不在该项目中或项目已关闭,内容已保留'
const GENERIC_DELIVERY_ERROR = '发送未成功,内容已保留;可修改后重试或移除'
function detail(id: string) {
return {
conversation: { id, projectId: 'p', title: id, createdAt: '', updatedAt: '' } as Conversation,
messages: [] as Message[], runs: [], activeHitl: [], lastSeq: 0, resumeSeq: 0,
}
}
class FakeEventSource {
onopen = null
onerror = null
onmessage = null
addEventListener() {}
close() {}
}
beforeEach(async () => {
vi.stubGlobal('EventSource', FakeEventSource)
vi.spyOn(api, 'conversationDetail').mockImplementation(async id => detail(id))
vi.spyOn(api, 'getPlan').mockResolvedValue(null)
vi.spyOn(api, 'getTodos').mockResolvedValue(null)
vi.spyOn(api, 'listCards').mockResolvedValue([])
vi.spyOn(api, 'listTasks').mockResolvedValue([])
vi.spyOn(api, 'listArtifacts').mockResolvedValue([])
vi.spyOn(api, 'surfaceList').mockResolvedValue([])
vi.spyOn(api, 'projectSurfaces').mockResolvedValue([])
vi.spyOn(api, 'listProjectCards').mockResolvedValue([])
await store.openProject('p')
await store.openConversation('a')
})
afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
describe('sendMessage rejection copy', () => {
it('keeps the message in the outbox with the removed-member copy on 403 not-project-member', async () => {
vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('403: conversation: not-project-member'))
expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected')
expect(store.getState().outbox[0]).toMatchObject({ id: 'kept-id', text: '这条还要继续吗', attachmentIds: [] })
expect(store.getState().outbox[0].deliveryError).toBe(REMOVED_DELIVERY_ERROR)
expect(store.getState().toast).toBe(REMOVED_TOAST)
})
it('keeps the generic copy for any other send failure', async () => {
vi.spyOn(api, 'sendMessage').mockRejectedValue(new Error('500: upstream down'))
expect(await store.sendMessage('这条还要继续吗', [], 'kept-id')).toBe('rejected')
expect(store.getState().outbox[0].deliveryError).toBe(GENERIC_DELIVERY_ERROR)
expect(store.getState().toast).toContain('发送失败:')
})
})