Merge pull request 'Add the no-project conversation view backed by scratch sessions' (#123) from feature/scratch-session-general-agent into main
This commit was merged in pull request #123.
This commit is contained in:
@@ -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)),
|
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)),
|
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)),
|
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)),
|
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),
|
deleteProject: (id: string) => auth.fetch(`${BASE}/api/projects/${id}`, { method: 'DELETE' }).then(checked),
|
||||||
|
|
||||||
|
|||||||
@@ -637,14 +637,14 @@ function ConversationSession() {
|
|||||||
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
{profiles.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</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
|
title={conversation?.allowCrossRead === false
|
||||||
? '本会话不读取其他会话记录(点击开启)'
|
? '本会话不读取其他会话记录(点击开启)'
|
||||||
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
: '本会话可读取同项目其他会话记录(需项目设置开启跨会话读取;点击关闭)'}
|
||||||
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
onClick={() => currentConvId && store.setCrossFlags(currentConvId, { allowCrossRead: conversation?.allowCrossRead === false })}>
|
||||||
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
|
{conversation?.allowCrossRead === false ? '关闭' : '允许'}
|
||||||
</button>
|
</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>
|
<label className="companion-setting"><span>这次工作的要求</span><SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} /></label>
|
||||||
</Collapsible>
|
</Collapsible>
|
||||||
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
<span title={connected ? '已连接' : '正在重新连接'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export function ConversationWork() {
|
|||||||
const cards = useStore(s => s.cards)
|
const cards = useStore(s => s.cards)
|
||||||
const conversationId = useStore(s => s.currentConvId)
|
const conversationId = useStore(s => s.currentConvId)
|
||||||
const projectId = useStore(s => s.currentProjectId)
|
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 messages = useStore(s => s.messages)
|
||||||
const actionStates = useStore(s => s.cardActionStates)
|
const actionStates = useStore(s => s.cardActionStates)
|
||||||
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
const rightCollapsed = useStore(s => s.ui.rightCollapsed)
|
||||||
@@ -56,7 +57,7 @@ export function ConversationWork() {
|
|||||||
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
onPin: (card: ActivityCard, pinned: boolean) => { void store.cardPin(card, pinned) },
|
||||||
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
onDismiss: (card: ActivityCard) => { void store.cardDismiss(card) },
|
||||||
onLocate: (card: ActivityCard) => { store.locateMessage(card.anchorMessageId) },
|
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} />
|
const view = (card: ActivityCard) => <ActivityCardView key={card.id} card={card} callbacks={callbacks} docBridge={docBridge} />
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
const artifacts = useStore(s => s.artifacts)
|
const artifacts = useStore(s => s.artifacts)
|
||||||
const currentConvId = useStore(s => s.currentConvId)
|
const currentConvId = useStore(s => s.currentConvId)
|
||||||
const currentProjectId = useStore(s => s.currentProjectId)
|
const currentProjectId = useStore(s => s.currentProjectId)
|
||||||
|
const scratchMode = useStore(s => !!s.scratchMode)
|
||||||
const showArchived = useStore(s => s.ui.showArchived)
|
const showArchived = useStore(s => s.ui.showArchived)
|
||||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||||
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
/** 审计入口能力门控(增量 2):命令面探测通过才展示「追溯审计」。 */
|
||||||
@@ -74,13 +75,15 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
|
|
||||||
const createConversation = async (projectId?: string) => {
|
const createConversation = async (projectId?: string) => {
|
||||||
try {
|
try {
|
||||||
|
if (projectId && scratchMode) await store.openProject(projectId)
|
||||||
await store.createTask(projectId)
|
await store.createTask(projectId)
|
||||||
onConversationOpen?.()
|
onConversationOpen?.()
|
||||||
} catch { /* store owns the actionable failure toast */ }
|
} catch { /* store owns the actionable failure toast */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const tools = buildWorkspaceEntries(serverWorkspaces, { rdmAudit: rdmAuditAvailable, rdmCockpit: rdmCockpitAvailable })
|
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 pinned = visible.filter(t => t.pinned)
|
||||||
const rest = visible.filter(t => !t.pinned)
|
const rest = visible.filter(t => !t.pinned)
|
||||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
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) }}>
|
<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} />
|
<Icon name={t.archived ? 'undo' : 'archive'} size={13} />
|
||||||
</button>
|
</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 }) }}>
|
onClick={e => { e.stopPropagation(); store.setCrossFlags(t.id, { crossPrivate: !t.crossPrivate }) }}>
|
||||||
<Icon name="shield" size={13} />
|
<Icon name="shield" size={13} />
|
||||||
</button>
|
</button>}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -121,7 +124,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
<button className="tsb-task-open" onClick={() => { store.openTask(t.id); onConversationOpen?.() }}>
|
||||||
<Icon name="message" size={14} />
|
<Icon name="message" size={14} />
|
||||||
<span className="tsb-task-t" title={t.title}>{t.title || '未命名对话'}</span>
|
<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>
|
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
|
||||||
</button>
|
</button>
|
||||||
{rowActions(t)}
|
{rowActions(t)}
|
||||||
@@ -136,9 +139,9 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
<div className="tsb-brand">
|
<div className="tsb-brand">
|
||||||
<button className="tsb-projrow" title="切换项目"
|
<button className="tsb-projrow" title="切换项目"
|
||||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||||||
<Icon name="folder" size={15} />
|
<Icon name={scratchMode ? 'message' : 'folder'} size={15} />
|
||||||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
<span className="tsb-projrow-name">{scratchMode ? '无项目对话' : currentProject?.name ?? '选择项目'}</span>
|
||||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
{stage && !scratchMode && <span className="tsb-projrow-stage">{stage}</span>}
|
||||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||||
</button>
|
</button>
|
||||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={onCollapse}><Icon name="panel-left" /></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-actions">
|
||||||
<div className="tsb-newtask">
|
<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()}>
|
onClick={() => createConversation()}>
|
||||||
<Icon name="plus" size={15} />新建对话
|
<Icon name="plus" size={15} />新建对话
|
||||||
</button>
|
</button>
|
||||||
@@ -156,12 +160,12 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
</div>
|
</div>
|
||||||
<div className="tsb-search">
|
<div className="tsb-search">
|
||||||
<Icon name="search" size={14} />
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="tsb-orgrow">
|
<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 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
<button className={`tsb-iconbtn ${showArchived ? 'active' : ''}`} title={showArchived ? '隐藏归档' : '查看归档'}
|
||||||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
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 && (
|
{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>
|
: workError ?? '从「新建对话」开始,描述你想完成的工作。'}</div>
|
||||||
)}
|
)}
|
||||||
{rest.map(t => taskRow(t))}
|
{rest.map(t => taskRow(t))}
|
||||||
@@ -215,7 +220,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
<kbd>{entry.shortcut}</kbd>
|
<kbd>{entry.shortcut}</kbd>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{currentProject && (
|
{currentProject && !scratchMode && (
|
||||||
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
<button className="tsb-tool" onClick={() => store.openProjectSettings(currentProject.id)}>
|
||||||
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
<span className="tsb-tool-icon"><Icon name="settings" size={14} /></span>
|
||||||
<span className="tsb-tool-name">项目设置</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)}>
|
<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 => {
|
{projects.map(p => {
|
||||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||||
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
const pstage = projectStateLabel(p.settings) ?? projectStage(p.settings)
|
||||||
@@ -246,7 +255,7 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
return (
|
return (
|
||||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||||
{p.name}
|
{p.name}
|
||||||
<kbd>{p.id === currentProjectId ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
|
<kbd>{p.id === currentProjectId && !scratchMode ? '当前 · ' : pstage ? `${pstage} · ` : ''}{mine ? '我创建 · ' : ''}{count} 会话</kbd>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -258,12 +267,14 @@ export function TaskSidebar({ onCollapse = store.toggleLeft, onConversationOpen,
|
|||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
<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 && <>
|
{canCreate && <>
|
||||||
<div className="tsb-menu-div" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem icon="folder" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
<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 => (
|
{projects.filter(p => p.id !== currentProjectId).slice(0, 6).map(p => (
|
||||||
<MenuItem key={p.id} onClick={() => { createConversation(p.id); setMenu(null) }}>在「{p.name}」新建对话</MenuItem>
|
<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 && <>
|
{rowTask && <>
|
||||||
<MenuItem icon="pin" onClick={() => { store.pinTask(rowTask.id, !rowTask.pinned); setMenu(null) }}>{rowTask.pinned ? '取消置顶' : '置顶'}</MenuItem>
|
<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>
|
<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" />
|
<div className="tsb-menu-div" />
|
||||||
<MenuItem icon="shield" onClick={() => { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'}</MenuItem>
|
<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>
|
<MenuItem icon="users" onClick={() => { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}</MenuItem>
|
||||||
|
|||||||
@@ -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),
|
onAction: (c: ActivityCard, id: string, payload?: WorkCardAction) => store.cardAction(c, id, payload),
|
||||||
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
|
onOpenRecord: (c: ActivityCard) => store.openWorkRecord(c),
|
||||||
onReference: (c: ActivityCard) => store.referenceWork(c),
|
onReference: (c: ActivityCard) => store.referenceWork(c),
|
||||||
@@ -30,8 +31,8 @@ const inboxCallbacks = {
|
|||||||
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
onPin: (c: ActivityCard, p: boolean) => store.cardPin(c, p),
|
||||||
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
onDismiss: (c: ActivityCard) => store.cardDismiss(c),
|
||||||
onLocate: (c: ActivityCard) => store.locateMessage(c.anchorMessageId),
|
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 不聚合)。 */
|
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
|
||||||
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
|
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
|
||||||
@@ -63,7 +64,9 @@ export function Workbench() {
|
|||||||
const cards = useStore(s => s.cards)
|
const cards = useStore(s => s.cards)
|
||||||
const plan = useStore(s => s.plan)
|
const plan = useStore(s => s.plan)
|
||||||
const todos = useStore(s => s.todos)
|
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 projectCards = useStore(s => s.projectCards)
|
||||||
const filter = useStore(s => s.ui.inboxFilter)
|
const filter = useStore(s => s.ui.inboxFilter)
|
||||||
const visible = inboxScope === 'project' ? projectCards : cards
|
const visible = inboxScope === 'project' ? projectCards : cards
|
||||||
@@ -87,7 +90,7 @@ export function Workbench() {
|
|||||||
</div>
|
</div>
|
||||||
{rightTab === 'files'
|
{rightTab === 'files'
|
||||||
? <FilesTab />
|
? <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'))
|
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']
|
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||||
filter: InboxFilter
|
filter: InboxFilter
|
||||||
inboxScope: 'conv' | 'project'
|
inboxScope: 'conv' | 'project'
|
||||||
projectCards: ActivityCard[]
|
projectCards: ActivityCard[]
|
||||||
cards: ActivityCard[]
|
cards: ActivityCard[]
|
||||||
|
scratch: boolean
|
||||||
}) {
|
}) {
|
||||||
const record = useStore(s => s.workRecord)
|
const record = useStore(s => s.workRecord)
|
||||||
const showCurrentTask = !hasActiveProgressCard(cards)
|
const showCurrentTask = !hasActiveProgressCard(cards)
|
||||||
@@ -122,16 +126,16 @@ function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
|||||||
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
{showCurrentTask ? <CurrentTaskCard plan={plan} todos={todos} /> : null}
|
||||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
<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)}` : ''}
|
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||||
</button>
|
</button>}
|
||||||
<span style={{ flex: 1 }} />
|
<span style={{ flex: 1 }} />
|
||||||
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
||||||
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
||||||
</div>
|
</div>
|
||||||
{inboxScope === 'project'
|
{inboxScope === 'project'
|
||||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
? <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 />
|
<WorkspaceSurfaces />
|
||||||
</>}
|
</>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface Conversation {
|
|||||||
allowCrossRead?: boolean; crossPrivate?: boolean;
|
allowCrossRead?: boolean; crossPrivate?: boolean;
|
||||||
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
|
permissionMode?: 'default' | 'plan' | 'auto_edit' | '';
|
||||||
sessionNote?: string;
|
sessionNote?: string;
|
||||||
|
/** 无项目对话:服务端内部项目只承载这一条会话,不出现在项目列表里。 */
|
||||||
|
scratch?: boolean;
|
||||||
createdAt: string; updatedAt: string;
|
createdAt: string; updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -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', () => {
|
describe('DefaultProjectDisabled', () => {
|
||||||
it('does not create a default project when the member has no projects yet', async () => {
|
it('does not create a default project when the member has no projects yet', async () => {
|
||||||
projects = []
|
projects = []
|
||||||
await store.refreshProjects()
|
await store.refreshProjects()
|
||||||
|
routeScratchSession()
|
||||||
requests = []
|
requests = []
|
||||||
await expect(store.createTask()).rejects.toThrow(/等待项目负责人邀请/)
|
await store.createTask()
|
||||||
expect(requests.filter(r => r.init?.method === 'POST')).toEqual([])
|
const posts = requests.filter(r => r.init?.method === 'POST').map(r => r.path)
|
||||||
expect(store.getState().toast).toMatch(/等待项目负责人邀请/)
|
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 () => {
|
it('does not treat a list that has not arrived as empty', async () => {
|
||||||
@@ -335,9 +348,12 @@ describe('DefaultProjectDisabled', () => {
|
|||||||
await store.refreshMe()
|
await store.refreshMe()
|
||||||
projects = []
|
projects = []
|
||||||
await store.refreshProjects()
|
await store.refreshProjects()
|
||||||
|
routeScratchSession()
|
||||||
requests = []
|
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.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 () => {
|
it('surfaces the stage-specific project error before any POST is sent', async () => {
|
||||||
|
|||||||
+90
-22
@@ -3,7 +3,7 @@
|
|||||||
import { useSyncExternalStore } from 'react'
|
import { useSyncExternalStore } from 'react'
|
||||||
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
import { type AdminZoneSection, type LinkedCapabilities, api, connectEvents } from './api'
|
||||||
import { auth } from './auth'
|
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 { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage, type WorkReference } from './outbox'
|
||||||
@@ -85,6 +85,8 @@ export interface UIState {
|
|||||||
taskOrg: 'project' | 'timeline'
|
taskOrg: 'project' | 'timeline'
|
||||||
showArchived: boolean
|
showArchived: boolean
|
||||||
lastProjectId: string | null
|
lastProjectId: string | null
|
||||||
|
/** 用户上次停留在无项目对话视图;刷新后回到同一视图。 */
|
||||||
|
lastScratch: boolean
|
||||||
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
/** 系统管理全屏视图(阶段② IA:仅管理员;入口账户菜单,纵深防御在 AdminZone 再拦一次)。 */
|
||||||
adminZoneOpen: boolean
|
adminZoneOpen: boolean
|
||||||
changePwOpen: 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 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 秒内可见。 */
|
/** KTD9 项目自动发现:可见页每 2 秒串行重查;单次请求预算 2 秒、渲染预留 1 秒,正常链路 5 秒内可见。 */
|
||||||
export const PROJECT_SYNC_INTERVAL_MS = 2000
|
export const PROJECT_SYNC_INTERVAL_MS = 2000
|
||||||
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
|
export const PROJECT_SYNC_REQUEST_BUDGET_MS = 2000
|
||||||
@@ -107,6 +114,8 @@ interface AppState {
|
|||||||
projectsLoaded: boolean
|
projectsLoaded: boolean
|
||||||
projectSync: { status: ProjectSyncStatus; failures: number }
|
projectSync: { status: ProjectSyncStatus; failures: number }
|
||||||
currentProjectId: string | null
|
currentProjectId: string | null
|
||||||
|
/** 无项目对话视图:currentProjectId 此时是无项目对话的内部项目,不在 projects 里。 */
|
||||||
|
scratchMode: boolean
|
||||||
tasks: Conversation[]
|
tasks: Conversation[]
|
||||||
me: Me | null
|
me: Me | null
|
||||||
currentConvId: string | null
|
currentConvId: string | null
|
||||||
@@ -181,6 +190,7 @@ filesView: 'list', workspaceSurfacePin: null,
|
|||||||
taskOrg: 'project',
|
taskOrg: 'project',
|
||||||
showArchived: false,
|
showArchived: false,
|
||||||
lastProjectId: null,
|
lastProjectId: null,
|
||||||
|
lastScratch: false,
|
||||||
adminZoneOpen: false,
|
adminZoneOpen: false,
|
||||||
changePwOpen: false,
|
changePwOpen: false,
|
||||||
projectSettingsId: null,
|
projectSettingsId: null,
|
||||||
@@ -210,7 +220,7 @@ const surfaceActionsInflight: Record<string, boolean> = {}
|
|||||||
let state: AppState = {
|
let state: AppState = {
|
||||||
contextObs: {},
|
contextObs: {},
|
||||||
projects: [], projectsLoaded: false, projectSync: { status: 'idle', failures: 0 },
|
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,
|
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: [],
|
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [], cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
|
||||||
serverWorkspaces: [],
|
serverWorkspaces: [],
|
||||||
@@ -348,6 +358,29 @@ function resetProjectScope() {
|
|||||||
setUI({ previewExpanded: false, filesView: 'list' })
|
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) {
|
function observeDetail(observations: Record<string, DetailObservation>, id: string, seq = state.lastSeq, terminal = false) {
|
||||||
const version = ++detailVersion
|
const version = ++detailVersion
|
||||||
observations[id] = { ...observations[id], version, seq,
|
observations[id] = { ...observations[id], version, seq,
|
||||||
@@ -582,18 +615,22 @@ async function mergeProjectList(projects: Project[]) {
|
|||||||
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
|
const membershipChanged = !state.projectsLoaded || projectIdsKey(state.projects) !== projectIdsKey(projects)
|
||||||
set({ projects, projectsLoaded: true, error: null })
|
set({ projects, projectsLoaded: true, error: null })
|
||||||
const current = state.currentProjectId
|
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]
|
const fallback = projects[0]
|
||||||
if (fallback) await store.openProject(fallback.id)
|
if (fallback) await store.openProject(fallback.id)
|
||||||
else {
|
else {
|
||||||
resetProjectScope()
|
resetProjectScope()
|
||||||
set({ currentProjectId: null })
|
set({ currentProjectId: null })
|
||||||
setUI({ lastProjectId: null })
|
setUI({ lastProjectId: null })
|
||||||
|
setScratchMode(true, false)
|
||||||
}
|
}
|
||||||
store.toast('你已不在原项目中,已清除该项目的入口')
|
store.toast('你已不在原项目中,已清除该项目的入口')
|
||||||
} else if (!current) {
|
} else if (!current) {
|
||||||
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
const next = projects.find(p => p.id === state.ui.lastProjectId) ?? projects[0]
|
||||||
if (next) await store.openProject(next.id)
|
if (next) await store.openProject(next.id)
|
||||||
|
else setScratchMode(true, false)
|
||||||
}
|
}
|
||||||
if (membershipChanged) await store.refreshTasks()
|
if (membershipChanged) await store.refreshTasks()
|
||||||
}
|
}
|
||||||
@@ -617,7 +654,7 @@ export const store = {
|
|||||||
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
for (const key of Object.keys(surfaceActionsInflight)) delete surfaceActionsInflight[key]
|
||||||
convRestoreDone = true
|
convRestoreDone = true
|
||||||
persistConvId(null)
|
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,
|
contextObs: {}, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
|
||||||
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
plan: null, todos: null, artifacts: [], surfaces: [], projectSurfaces: [],
|
||||||
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
cards: [], projectCards: [], cardActionStates: {}, workRecord: null, workReference: null, inboxScope: 'conv',
|
||||||
@@ -626,7 +663,7 @@ export const store = {
|
|||||||
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
|
rdmDocReviewAvailable: false, rdmPermMatrixAvailable: false, rdmRender: NO_RENDER_CAPABILITIES,
|
||||||
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
connected: false, posting: false, error: null, toast: null, outbox: [], outboxDraining: false,
|
||||||
previewTabs: {}, previewActive: {}, previewOpenIntent: 0 })
|
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,
|
settingsOpen: false, changePwOpen: false, trackerRunId: null, workspaceSurfacePin: null,
|
||||||
previewExpanded: false, filesView: 'list', showArchived: false })
|
previewExpanded: false, filesView: 'list', showArchived: false })
|
||||||
},
|
},
|
||||||
@@ -634,16 +671,23 @@ export const store = {
|
|||||||
async refreshProjects() {
|
async refreshProjects() {
|
||||||
const epoch = identityEpoch
|
const epoch = identityEpoch
|
||||||
const request = ++projectsRequest
|
const request = ++projectsRequest
|
||||||
|
let scratch = false
|
||||||
try {
|
try {
|
||||||
const projects = await api.listProjects()
|
const projects = await api.listProjects()
|
||||||
if (epoch !== identityEpoch || request !== projectsRequest) return
|
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 })
|
scratch = projects.length === 0 || state.ui.lastScratch
|
||||||
if (current) await store.openProject(current.id)
|
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) {
|
} catch (e) {
|
||||||
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
if (epoch === identityEpoch && request === projectsRequest) set({ error: projectActionError(e) })
|
||||||
}
|
}
|
||||||
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
if (epoch === identityEpoch && request === projectsRequest) await store.refreshTasks()
|
||||||
|
if (scratch && epoch === identityEpoch && request === projectsRequest) await store.enterScratchMode(state.ui.lastScratch)
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
|
/** 自动发现的一次重查:与 refreshProjects 共用请求代次,迟到/旧身份响应丢弃;失败抛出供退避。 */
|
||||||
@@ -674,14 +718,16 @@ export const store = {
|
|||||||
const request = ++tasksRequest
|
const request = ++tasksRequest
|
||||||
try {
|
try {
|
||||||
const tasks = await api.listTasks(includeArchived)
|
const tasks = await api.listTasks(includeArchived)
|
||||||
if (epoch !== identityEpoch || request !== tasksRequest) return
|
if (epoch !== identityEpoch || request !== tasksRequest || !Array.isArray(tasks)) return
|
||||||
set({ tasks })
|
set({ tasks })
|
||||||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||||
if (!convRestoreDone) {
|
if (!convRestoreDone) {
|
||||||
convRestoreDone = true
|
convRestoreDone = true
|
||||||
const saved = savedConvId()
|
const saved = savedConvId()
|
||||||
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
|
const restored = saved ? tasks.find(t => t.id === saved && !t.archived) : undefined
|
||||||
store.openConversation(saved)
|
if (restored && !state.currentConvId) {
|
||||||
|
if (restored.scratch) await store.openTask(restored.id)
|
||||||
|
else store.openConversation(restored.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch { /* 列表竞态,静默 */ }
|
} catch { /* 列表竞态,静默 */ }
|
||||||
@@ -719,22 +765,41 @@ export const store = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** 打开真实项目:同时离开无项目对话视图。 */
|
||||||
async openProject(projectId: string) {
|
async openProject(projectId: string) {
|
||||||
if (state.currentProjectId !== projectId) resetProjectScope()
|
setScratchMode(false)
|
||||||
set({ currentProjectId: projectId })
|
await bindProject(projectId, true)
|
||||||
if (store.getState().inboxScope === 'project') {
|
},
|
||||||
store.loadProjectCards()
|
|
||||||
store.connectProject()
|
/** 进入无项目对话视图:当前会话不是无项目对话时清空会话视图,并打开最近一条未归档的无项目对话。 */
|
||||||
}
|
async enterScratchMode(remember = true) {
|
||||||
store.setUI({ lastProjectId: projectId })
|
const epoch = identityEpoch
|
||||||
await store.refreshArtifacts(projectId)
|
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 用于"新建于指定项目"。
|
/** 新建任务(=会话):默认当前项目上下文;显式 projectId 用于"新建于指定项目"。
|
||||||
* 没有项目时不代建「默认项目」:列表未到达或无创建能力时自动建项目会误触权限与同名冲突。 */
|
* 无项目对话视图、或项目列表已到达且没有真实项目时,新建无项目对话;从不代建真实项目。 */
|
||||||
async createTask(projectId?: string) {
|
async createTask(projectId?: string) {
|
||||||
const epoch = identityEpoch
|
const epoch = identityEpoch
|
||||||
|
const changed = () => { if (epoch !== identityEpoch) throw new Error('登录状态已变化,请重新选择项目后再试') }
|
||||||
try {
|
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
|
let pid = projectId ?? state.currentProjectId
|
||||||
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
if (!pid && state.projects.length > 0) pid = state.projects[0].id
|
||||||
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
|
if (!pid) throw new Error(noProjectError(state.projectsLoaded, canCreateProject(state.me)))
|
||||||
@@ -761,12 +826,15 @@ export const store = {
|
|||||||
if (title) store.toast('已在当前项目创建任务')
|
if (title) store.toast('已在当前项目创建任务')
|
||||||
},
|
},
|
||||||
|
|
||||||
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确)。 */
|
/** 打开任务:跨项目点击时切换项目上下文(工件/上传归属正确);视图随任务进入或离开无项目对话。 */
|
||||||
async openTask(convId: string) {
|
async openTask(convId: string) {
|
||||||
const epoch = identityEpoch
|
const epoch = identityEpoch
|
||||||
const t = state.tasks.find(x => x.id === convId)
|
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 (epoch !== identityEpoch) return
|
||||||
|
if (t) setScratchMode(!!t.scratch)
|
||||||
await store.openConversation(convId)
|
await store.openConversation(convId)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user