Rebuild the workbench shell around projects and card flow #34

Merged
weichen merged 3 commits from feature/s4-shell-overhaul into main 2026-10-02 01:04:36 +00:00
17 changed files with 496 additions and 140 deletions
+10 -1
View File
@@ -15,6 +15,8 @@ import { AgentTracker } from './components/AgentTracker'
import { SettingsModal } from './components/SettingsModal'
import { ProjectSettingsModal } from './components/ProjectSettingsModal'
import { DocViewer } from './components/DocViewer'
import { TopBar } from './components/TopBar'
import { workspaceEntries } from './components/Workspaces'
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
@@ -113,8 +115,11 @@ export function App() {
return <div className="login-gate"><div className="login-card"><p>连接服务中…</p></div></div>
}
const tools = workspaceEntries()
return (
<div className="app">
<TopBar />
<div className="app-body">
{!ui.leftCollapsed ? (
<>
<div className="left-nav" style={{ width: ui.leftWidth }}>
@@ -127,9 +132,12 @@ export function App() {
<div className="icon-only">
<button title="展开侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
<button title="新建任务(⌘N)" onClick={() => store.createTask()}><Icon name="plus" /></button>
<button title="收件箱" onClick={() => store.openInbox()}><Icon name="inbox" />
<button title="收件箱" onClick={() => store.focusPending()}><Icon name="inbox" />
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
</button>
{tools.map(entry => (
<button key={entry.key} title={`${entry.title}(${entry.shortcut})`} onClick={() => store.openWorkspace(entry.key)}>{entry.icon}</button>
))}
<button title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" /></button>
</div>
</div>
@@ -164,6 +172,7 @@ export function App() {
</div>
</div>
)}
</div>
<Workspaces />
{trackerRunId && <AgentTracker />}
+35 -11
View File
@@ -6,6 +6,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
import { marked } from 'marked'
import { api } from '../api'
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
import { liveToMessage, showProcessInline } from '../live'
import type { Message, Part, HITLCard, ActivityCard, Artifact } from '../contracts'
import { UIRenderer, InlineConfirmCard, hasOpenConfirm, CardAnchor } from '@ui'
@@ -13,6 +14,10 @@ import { docBridge } from './Workbench'
import { splitTimeline, artifactIcon, humanSize, artifactIdsIn, markMissingArtifacts, workedLabel } from '../messageparts'
import { ProcessTimeline } from './ProcessTimeline'
function hasFiles(dt: DataTransfer | null): boolean {
return !!dt && Array.from(dt.types).includes('Files')
}
// marked 走标准 Markdown 语义(不开 breaks):模型输出按块级排版渲染,
// 行内换行交给段落折叠,避免与 CSS 块间距叠加出松散排版。
@@ -368,9 +373,11 @@ export function Conversation() {
const cards = useStore(s => s.cards)
const projectName = useStore(s => s.projects.find(p => p.id === s.currentProjectId)?.name ?? '项目')
const runtimeMock = useStore(s => s.runtimeMock)
const [text, setText] = useState('')
const [attachmentIds, setAttachmentIds] = useState<string[]>([])
const [attachmentNames, setAttachmentNames] = useState<string[]>([])
const [dragDepth, setDragDepth] = useState(0)
const bodyRef = useRef<HTMLDivElement>(null)
artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a]))
@@ -401,14 +408,29 @@ export function Conversation() {
<div className="chat-body"><div className="empty-state">
<div style={{ fontSize: 40 }}>⌘</div>
<div>选择或创建一个会话开始</div>
<div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 演示模式可在 设置 → runtime 开启,无需模型密钥</div>
{runtimeMock
? <div style={{ fontSize: 'var(--fs-ui-sm)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error</div>
: <div style={{ fontSize: 'var(--fs-ui-sm)' }}>演示模式可在设置里打开,无需模型密钥</div>}
</div></div>
</div>
)
}
return (
<div className="chat">
<div className={`chat ${dragDepth > 0 ? 'is-dragging' : ''}`}
onDragEnter={e => { if (!hasFiles(e.dataTransfer)) return; e.preventDefault(); setDragDepth(d => d + 1) }}
onDragOver={e => { if (hasFiles(e.dataTransfer)) e.preventDefault() }}
onDragLeave={() => setDragDepth(d => Math.max(0, d - 1))}
onDrop={async e => {
if (!e.dataTransfer?.files?.length) return
e.preventDefault()
setDragDepth(0)
for (const f of Array.from(e.dataTransfer.files)) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
}}>
{dragDepth > 0 && <div className="drop-veil">释放以上传资料</div>}
<div className="chat-header">
<span className="project-badge" title="当前项目">{projectName}</span>
<div className="chat-title">{conversation?.title ?? '会话'}</div>
@@ -432,14 +454,16 @@ export function Conversation() {
</div>
<div className="chat-body" ref={bodyRef}
onDragOver={e => { e.preventDefault(); e.currentTarget.classList.add('drag-over') }}
onDragLeave={e => e.currentTarget.classList.remove('drag-over')}
onDrop={async e => {
e.preventDefault(); e.currentTarget.classList.remove('drag-over')
for (const f of Array.from(e.dataTransfer.files ?? [])) {
const id = await store.uploadAttachment(f)
if (id) { setAttachmentIds(a => [...a, id]); setAttachmentNames(a => [...a, f.name]) }
}
onScroll={() => {
const src = bodyRef.current
if (!src) return
const ratio = scrollRatio(src)
if (ratio === null) return
const dst = document.querySelector('[data-scroll-sync="inbox"]')
if (!dst) return
const top = scrollTopForRatio(dst, ratio)
if (top === null) return
dst.scrollTop = top
}}>
{messages.filter(m => m.role !== 'assistant' || !m.runId || runs.find(r => r.id === m.runId)?.kind !== 'subagent').map(m => {
if (m.role === 'user') {
@@ -469,7 +493,7 @@ export function Conversation() {
{messages.length === 0 && <div className="empty-state">
<span className="es-mark"><Icon name="sparkles" size={22} /></span>
<div>发送消息开始对话</div>
<div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>
{runtimeMock && <div style={{ fontSize: 'var(--fs-ui-sm)', color: 'var(--text-faint)' }}>mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help</div>}
</div>}
</div>
+26 -4
View File
@@ -4,7 +4,8 @@
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
import { useEffect, useMemo, useState } from 'react'
import { api } from '../api'
import { store } from '../store'
import { store, useStore } from '../store'
import type { ThemeChoice } from '../theme'
import type { SettingDesc } from '../contracts'
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
@@ -22,6 +23,7 @@ export function SettingsModal() {
const [, setUser] = useState<Record<string, any>>({})
const [draft, setDraft] = useState<Record<string, any>>({})
const [saved, setSaved] = useState(false)
const theme = useStore(s => s.ui.theme)
useEffect(() => {
api.getSettings().then(s => {
@@ -63,15 +65,35 @@ export function SettingsModal() {
</>
) : undefined}
>
{section === 'settings' && Object.entries(groups).map(([g, descs]) => (
{section === 'settings' && (
<SettingsGroup title="外观">
<SettingRow label="主题" desc="墨绿是可选身份皮肤。跟随系统只在深色与浅色之间切换。深色和浅色会随设置保存" effect="hot">
<select value={theme} onChange={e => {
const next = e.target.value as ThemeChoice
store.setUI({ theme: next })
if (next === 'dark' || next === 'light') setDraft(x => ({ ...x, 'ui.theme': next }))
}}>
<option value="dark">深色</option>
<option value="light">浅色</option>
<option value="molv">墨绿</option>
<option value="system">跟随系统</option>
</select>
</SettingRow>
</SettingsGroup>
)}
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
const rows = descs.filter(d => d.key !== 'ui.theme')
if (rows.length === 0) return null
return (
<SettingsGroup key={g} title={groupNames[g] ?? g}>
{descs.map(d => (
{rows.map(d => (
<SettingRow key={d.key} label={d.label} desc={d.description} effect={d.effect}>
<SettingInput desc={d} value={draft[d.key] ?? ''} onChange={v => setDraft(x => ({ ...x, [d.key]: v }))} />
</SettingRow>
))}
</SettingsGroup>
))}
)
})}
</SettingsShell>
)
}
+61 -60
View File
@@ -1,20 +1,14 @@
// 左栏(方案 A「任务中心」,提案 2026-09-30 R1):单一主创建入口(P6)+
// 组织视图(P8:按项目/时间线)+ 置顶/归档 + 账户区(P7)。
// 重设计方案 阶段②(IA 对话优先):工件/代理/插件快捷条撤出普通用户界面——
// 工件走右栏「工件」页签,插件工作区归「系统管理」(管理员专属全屏视图)。
// 左栏:项目列表、当前项目的会话、体系工具、账户。
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
import { useEffect, useRef, useState } from 'react'
import { store, useStore } from '../store'
import { auth } from '../auth'
import { api } from '../api'
import { Icon, BrandMark } from '@ui'
import { Icon } from '@ui'
import type { Conversation } from '../contracts'
import { groupByProject, groupByTimeline, relativeTime, filterTasks, type TaskOrg } from '../tasklist'
const GROUPS_KEY = 'as.taskGroups'
function loadCollapsed(): Record<string, boolean> {
try { return JSON.parse(localStorage.getItem(GROUPS_KEY) ?? '{}') } catch { return {} }
}
import { relativeTime, filterTasks } from '../tasklist'
import { projectStage } from '../projectmeta'
import { buildWorkspaceEntries } from '../workspaces'
/** 小型弹出菜单:锚点下方渲染,点击外部关闭。 */
function Menu({ anchor, onClose, children }: { anchor: HTMLElement | null; onClose: () => void; children: React.ReactNode }) {
@@ -48,16 +42,15 @@ export function TaskSidebar() {
const me = useStore(s => s.me)
const currentConvId = useStore(s => s.currentConvId)
const currentProjectId = useStore(s => s.currentProjectId)
const org = useStore(s => s.ui.taskOrg)
const showArchived = useStore(s => s.ui.showArchived)
const serverWorkspaces = useStore(s => s.serverWorkspaces)
const theme = useStore(s => s.ui.theme)
const pendingBadge = useStore(s =>
s.cards.filter(c => c.status === 'action_required').length +
s.projectCards.filter(c => c.status === 'action_required').length)
const [q, setQ] = useState('')
const [collapsed, setCollapsed] = useState<Record<string, boolean>>(loadCollapsed)
const [menu, setMenu] = useState<{ kind: 'new' | 'filter' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
const [creatingProject, setCreatingProject] = useState(false)
const [newProjectName, setNewProjectName] = useState('')
@@ -72,19 +65,10 @@ export function TaskSidebar() {
return () => window.removeEventListener('keydown', h, true)
}, [])
const tglGroup = (key: string) => {
setCollapsed(c => {
const next = { ...c, [key]: !c[key] }
try { localStorage.setItem(GROUPS_KEY, JSON.stringify(next)) } catch { /* ignore */ }
return next
})
}
const setOrg = (o: TaskOrg) => store.setUI({ taskOrg: o })
const visible = filterTasks(tasks, q)
const tools = buildWorkspaceEntries(serverWorkspaces)
const visible = filterTasks(tasks.filter(t => t.projectId === currentProjectId), q)
const pinned = visible.filter(t => t.pinned)
const rest = visible.filter(t => !t.pinned)
const groups = org === 'project' ? groupByProject(rest, projects) : groupByTimeline(rest)
const currentProject = projects.find(p => p.id === currentProjectId)
const rowTask = menu?.kind.startsWith('row:') ? tasks.find(t => t.id === menu.kind.slice(4)) : null
const rowMenuAnchor = menu && menu.kind.startsWith('row:') ? menu.anchor : null
@@ -102,13 +86,12 @@ export function TaskSidebar() {
</span>
)
const taskRow = (t: Conversation, badge?: string) => (
const taskRow = (t: Conversation) => (
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
onClick={() => store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
<span className="tsb-task-t" title={t.title}>{t.title || '未命名任务'}</span>
{t.crossPrivate && <span className="tsb-pbadge" title="私有:不被其他会话读取"><Icon name="shield" size={11} /></span>}
{badge && <span className="tsb-pbadge">{badge}</span>}
<span className="tsb-task-tm">{relativeTime(t.updatedAt)}</span>
{rowActions(t)}
</div>
@@ -117,8 +100,7 @@ export function TaskSidebar() {
return (
<>
<div className="tsb-brand">
<BrandMark size={24} />
<span className="tsb-name">Agent-System</span>
<span className="tsb-name">项目</span>
<span style={{ flex: 1 }} />
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
</div>
@@ -138,12 +120,9 @@ export function TaskSidebar() {
</div>
</div>
{/* 组织视图切换(P8)+ 过滤 */}
<div className="tsb-orgrow">
<div className="tsb-pills">
<button className={`tsb-pill ${org === 'project' ? 'on' : ''}`} onClick={() => setOrg('project')}>按项目</button>
<button className={`tsb-pill ${org === 'timeline' ? 'on' : ''}`} onClick={() => setOrg('timeline')}>时间线</button>
</div>
<span className="tsb-seclabel" style={{ padding: 0 }}>本项目会话</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>
</div>
@@ -164,34 +143,55 @@ export function TaskSidebar() {
</div>
)}
<div className="tsb-seclabel">项目</div>
{projects.map(p => {
const stage = projectStage(p.settings)
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
return (
<button key={p.id} className={`tsb-project ${p.id === currentProjectId ? 'active' : ''}`}
onClick={() => {
const next = tasks.find(t => t.projectId === p.id && t.id === currentConvId)
?? tasks.find(t => t.projectId === p.id && !t.archived)
if (next) store.openTask(next.id)
else store.openProject(p.id)
}}>
<span className="tsb-project-name">{p.name}</span>
<span className="tsb-project-meta">{stage ? `${stage} · ` : ''}{count} 个会话</span>
</button>
)
})}
{projects.length === 0 && <div className="tsb-empty">还没有项目——从「新建任务」旁的菜单创建</div>}
{pinned.length > 0 && (
<>
<div className="tsb-seclabel">置顶</div>
{pinned.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
{pinned.map(t => taskRow(t))}
</>
)}
{groups.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">尚无任务——点击「新建任务」开始<br />(项目内素材上传、检索与生成全程可在对话中完成)</div>
{rest.length === 0 && pinned.length === 0 && (
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
)}
{rest.map(t => taskRow(t))}
{groups.map(g => (
<div key={g.key} className={`tsb-group ${collapsed[g.key] ? 'closed' : ''}`}>
{org === 'project' ? (
<div className="tsb-group-head" onClick={() => tglGroup(g.key)}>
<span className="tsb-chev"><Icon name="chevron-right" size={13} /></span>
<span className="tsb-group-name" title={g.label}>{g.label}</span>
<span className="tsb-group-cnt">{g.tasks.length}</span>
<span style={{ flex: 1 }} />
<button className="tsb-group-add" title="项目设置(含跨会话读取)" onClick={e => { e.stopPropagation(); if (g.projectId) store.openProjectSettings(g.projectId) }}><Icon name="settings" size={13} /></button>
<button className="tsb-group-add" title="在此项目新建任务" onClick={e => { e.stopPropagation(); store.createTask(g.projectId) }}><Icon name="plus" size={13} /></button>
</div>
) : (
<div className="tsb-seclabel">{g.label}</div>
)}
{!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
</div>
<div className="tsb-seclabel">
体系工具
{pendingBadge > 0 && <span className="tsb-tool-badge" title="待处理">{pendingBadge}</span>}
</div>
{tools.map(entry => (
<button key={entry.key} className="tsb-tool" title={entry.shortcut} onClick={() => store.openWorkspace(entry.key)}>
<span className="tsb-tool-icon">{entry.icon}</span>
<span className="tsb-tool-name">{entry.title}</span>
<kbd>{entry.shortcut}</kbd>
</button>
))}
{serverWorkspaces.length === 0 && <div className="tsb-tool-note">领域工具由系统级插件提供</div>}
{currentProject && (
<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>
</button>
)}
</div>
{/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */}
@@ -205,7 +205,7 @@ export function TaskSidebar() {
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
</button>
<button className="tsb-iconbtn" title="设置" onClick={() => store.openSettings(true)}><Icon name="settings" size={15} /></button>
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.openInbox()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
{pendingBadge > 0 && <button className="tsb-iconbtn active" title={`待办 ${pendingBadge}`} onClick={() => store.focusPending()}><Icon name="inbox" size={15} />{pendingBadge}</button>}
</div>
{/* 弹出菜单们 */}
@@ -226,7 +226,7 @@ export function TaskSidebar() {
<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>
<div className="tsb-menu-div" />
<MenuItem icon="clock" onClick={() => { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看</MenuItem>
<MenuItem icon="settings" onClick={() => { if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置</MenuItem>
</>}
</Menu>
@@ -240,11 +240,12 @@ export function TaskSidebar() {
</div>
<div className="tsb-menu-div" />
<MenuItem icon="settings" onClick={() => { store.openSettings(true); setMenu(null) }}>偏好设置</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="inbox" onClick={() => { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''}</MenuItem>
<MenuItem icon="key" onClick={() => { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码…</MenuItem>
<MenuItem icon={theme === 'dark' ? 'sun' : 'moon'} onClick={() => { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}>
{theme === 'dark' ? '切换浅色主题' : '切换深色主题'}
</MenuItem>
<MenuItem icon="moon" onClick={() => { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''}</MenuItem>
<MenuItem icon="sun" onClick={() => { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''}</MenuItem>
<MenuItem onClick={() => { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''}</MenuItem>
{me?.isAdmin && <MenuItem icon="shield" onClick={() => { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…</MenuItem>}
{me?.auth && <>
<div className="tsb-menu-div" />
+24
View File
@@ -0,0 +1,24 @@
// 顶栏:产品标识、当前项目与阶段徽章、待处理铃铛。不参与具体操作。
import { BrandMark, Icon, pendingCount } from '@ui'
import { store, useStore } from '../store'
import { projectStage } from '../projectmeta'
export function TopBar() {
const project = useStore(s => s.projects.find(p => p.id === s.currentProjectId))
const pending = useStore(s => pendingCount(s.cards) + pendingCount(s.projectCards))
const stage = projectStage(project?.settings)
return (
<header className="topbar">
<BrandMark size={22} />
<span className="topbar-name">Agent-System</span>
{project && <span className="topbar-project" title="当前项目">{project.name}</span>}
{stage && <span className="topbar-stage" title="当前阶段">{stage}</span>}
<span className="topbar-spacer" />
<button className="topbar-bell" title={pending > 0 ? `待处理 ${pending},打开收件箱` : '收件箱'}
onClick={() => store.focusPending()}>
<Icon name="inbox" size={16} />
{pending > 0 && <span className="rail-badge" data-testid="topbar-pending">{pending}</span>}
</button>
</header>
)
}
+24 -31
View File
@@ -1,9 +1,9 @@
// 右栏:收件箱与文件。文件预览在对话右侧的预览区,不再占右栏页签。
// 右栏:卡片流。全部 / 待处理筛选、收纳、本会话与项目范围。
// 文件不占右栏页签——工件走体系工具(⌘)与预览区,一个对象一个入口。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { InboxList, pendingCount, type ActivityCard, type DocBridge } from '@ui'
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
import { Icon } from '@ui'
import { ArtifactsPanel } from './ArtifactsPanel'
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
// 打开语义走 requestPreview 唯一入口(预览区)。
@@ -24,7 +24,7 @@ const inboxCallbacks = {
}
/** 项目收件箱视图(CF3/D49/D51):项目级卡片 ∪ 其他会话待办(done 不聚合)。 */
function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxFilter }) {
const own = cards.filter(c => !c.conversationId)
const cross = cards.filter(c => !!c.conversationId)
if (cards.length === 0) {
@@ -34,12 +34,12 @@ function ProjectInbox({ cards }: { cards: ActivityCard[] }) {
<>
<div className="inbox-zone-label">📁 项目级卡片(跨会话可见)</div>
{own.length > 0
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} />
? <InboxList cards={own} callbacks={inboxCallbacks} docBridge={docBridge} filter={filter} />
: <div className="inbox-empty" style={{ padding: '4px 14px' }}>暂无——会话卡点 🗂 即留档至此</div>}
{cross.length > 0 && (
<>
<div className="inbox-zone-label">🕓 其他会话待办</div>
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} />
<InboxList cards={cross} callbacks={inboxCallbacks} docBridge={docBridge} showPinnedZone={false} filter={filter} />
</>
)}
</>
@@ -52,39 +52,32 @@ export function Workbench() {
const todos = useStore(s => s.todos)
const inboxScope = useStore(s => s.inboxScope)
const projectCards = useStore(s => s.projectCards)
const pending = pendingCount(cards) + pendingCount(projectCards)
const rightTab = useStore(s => s.ui.rightTab)
const showFiles = rightTab === 'files'
const filter = useStore(s => s.ui.inboxFilter)
const visible = inboxScope === 'project' ? projectCards : cards
const pending = pendingCount(visible)
return (
<>
<div className="tabs">
<button className={`tab ${!showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'inbox' })}
title="收件箱">收件箱{pending > 0 ? ` ${pending}` : ''}</button>
<button className={`tab ${showFiles ? 'active' : ''}`} onClick={() => store.setUI({ rightTab: 'files', rightCollapsed: false })}
title="文件(与 ⌘1 工件面板同一份列表)">文件</button>
<button className={`tab ${filter === 'all' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'all' })}
title="全部卡片">全部</button>
<button className={`tab ${filter === 'todo' ? 'active' : ''}`} onClick={() => store.setUI({ inboxFilter: 'todo' })}
title="只看待处理">待处理{pending > 0 ? ` ${pending}` : ''}</button>
<span className="spacer" style={{ flex: 1 }} />
<button className="tab tab-action" title="收纳已完成卡(done→关闭;已确认卡留痕不动)" onClick={() => store.tidyCards()}><Icon name="check" size={13} /> 收纳</button>
<button className="tab" title="收起收件箱" onClick={store.toggleRight}><Icon name="chevron-right" size={14} /></button>
</div>
{showFiles ? (
<div className="tab-body">
<ArtifactsPanel />
<div className="tab-body" data-scroll-sync="inbox">
<CurrentTaskCard plan={plan} todos={todos} />
<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')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</div>
) : (
<div className="tab-body">
<CurrentTaskCard plan={plan} todos={todos} />
<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')}>
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
</button>
</div>
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} />}
</div>
)}
{inboxScope === 'project'
? <ProjectInbox cards={projectCards} filter={filter} />
: <InboxList cards={cards} docBridge={docBridge} callbacks={inboxCallbacks} filter={filter} />}
</div>
</>
)
}
+10 -19
View File
@@ -1,26 +1,16 @@
// 工作区弹层(提案 T4/D25):工件/代理 经 OverlayHost 呈现。
// 重设计方案 阶段②(IA 对话优先):插件面板/服务端声明工作区属系统能力,
// 只进「系统管理」分区(AdminZone),不占普通用户的 ⌘1–9;清单按角色收窄。
// 工作区弹层(提案 T4/D25):深读经 OverlayHost 单层呈现。
// 服务端声明的工作区(领域插件槽位)对所有用户可见;插件管理面板仍只在系统管理区。
import { store, useStore } from '../store'
import type { Run } from '../contracts'
import { OverlayHost, type DocBridge } from '@ui'
import { docBridge } from './Workbench'
import { AgentRow } from './Workbench'
import { ArtifactsPanel } from './ArtifactsPanel'
import { buildWorkspaceEntries, type WorkspaceEntry } from '../workspaces'
/** 工作区清单(顺序即 ⌘ 序号):内置在前,插件面板/声明工作区仅管理员可见。 */
export function workspaceEntries(): { key: string; title: string; icon: string; shortcut: string }[] {
const s = store.getState()
const admin = !!s.me?.isAdmin
const out = [
{ key: 'artifacts', title: '工件', icon: '📦', shortcut: '⌘1' },
{ key: 'agents', title: '代理', icon: '🤖', shortcut: '⌘2' },
...(admin ? [
...s.pluginPanels.map((p, i) => ({ key: `plugin:${p.id}`, title: p.title, icon: '🧩', shortcut: `⌘${i + 3}` })),
...s.serverWorkspaces.map((w, i) => ({ key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂', shortcut: w.shortcut ? `⌘${w.shortcut}` : `⌘${i + 3 + s.pluginPanels.length}` })),
] : []),
]
return out.slice(0, 9)
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
export function workspaceEntries(): WorkspaceEntry[] {
return buildWorkspaceEntries(store.getState().serverWorkspaces)
}
export function Workspaces() {
@@ -34,13 +24,13 @@ export function Workspaces() {
const close = () => store.closeWorkspace()
if (active === 'artifacts') {
return <OverlayHost title="工件" icon="📦" shortcut="⌘1" badge={artifacts.length || undefined} onClose={close}>
return <OverlayHost title="工件" icon="📦" shortcut={entry?.shortcut ?? '⌘1'} sayHint="打开工件" badge={artifacts.length || undefined} onClose={close}>
<ArtifactsPanel />
</OverlayHost>
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
return <OverlayHost title="代理" icon="🤖" shortcut="⌘2" onClose={close}>
return <OverlayHost title="代理" icon="🤖" shortcut={entry?.shortcut ?? '⌘2'} sayHint="打开代理" onClose={close}>
<AgentsPanel runs={convRuns} />
</OverlayHost>
}
@@ -51,7 +41,8 @@ export function Workspaces() {
}
if (active.startsWith('ws:')) {
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
return <OverlayHost title={ws?.title ?? '工作区'} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} onClose={close}
const title = ws?.title ?? '工作区'
return <OverlayHost title={title} icon={ws?.icon || '🗂'} shortcut={entry?.shortcut} sayHint={`打开${title}`} onClose={close}
nodes={ws?.body ?? []} docBridge={docBridge as DocBridge} onRefresh={() => store.refreshPlugins()} />
}
return null
+5
View File
@@ -0,0 +1,5 @@
/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
export function projectStage(settings?: Record<string, unknown> | null): string | null {
const stage = settings?.stage
return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
}
+30
View File
@@ -0,0 +1,30 @@
// 中栏 → 右栏单向滚动同步,以及互定位后的 1.2s 闪烁。
// 比例按可滚动范围计算;任一侧没有溢出时不同步,避免把静止栏拽动。
export interface ScrollBox {
scrollTop: number
scrollHeight: number
clientHeight: number
}
export function scrollRatio(box: ScrollBox): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
return box.scrollTop / range
}
export function scrollTopForRatio(box: Pick<ScrollBox, 'scrollHeight' | 'clientHeight'>, ratio: number): number | null {
const range = box.scrollHeight - box.clientHeight
if (range <= 0) return null
const clamped = Math.min(1, Math.max(0, ratio))
return clamped * range
}
/** 给已定位的节点加上 mk-flash,1.2s 后摘掉。 */
export function flashElement(el: Element | null) {
if (!el || typeof window === 'undefined') return
el.classList.remove('mk-flash')
void (el as HTMLElement).offsetWidth
el.classList.add('mk-flash')
window.setTimeout(() => el.classList.remove('mk-flash'), 1200)
}
+64 -10
View File
@@ -5,6 +5,8 @@ import { api, connectEvents } from './api'
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolResult, applyArtifactCreated, type LiveItem } from './live'
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
import { enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
import { flashElement } from './scrollsync'
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
import type {
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
@@ -32,7 +34,9 @@ export interface UIState {
rightWidth: number
rightCollapsed: boolean
rightTab: string
theme: 'dark' | 'light'
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
inboxFilter: 'all' | 'todo'
theme: ThemeChoice
autoCollapseProcess: boolean
settingsOpen: boolean
trackerRunId: string | null
@@ -69,6 +73,8 @@ interface AppState {
cards: ActivityCard[]
projectCards: ActivityCard[]
inboxScope: 'conv' | 'project'
/** runtime.mock:只控制演示指令提示,不改变对话能力。 */
runtimeMock: boolean
profiles: AgentProfile[]
pluginPanels: PluginPanel[]
serverWorkspaces: { id: string; title: string; icon?: string; shortcut?: string; body?: any[] }[]
@@ -88,6 +94,7 @@ const DEFAULT_UI: UIState = {
rightWidth: 340,
rightCollapsed: false,
rightTab: 'inbox',
inboxFilter: 'all',
activeWorkspace: null,
theme: 'dark',
autoCollapseProcess: true,
@@ -106,7 +113,12 @@ const DEFAULT_UI: UIState = {
function loadUI(): UIState {
try {
const raw = localStorage.getItem('as.ui')
if (raw) return { ...DEFAULT_UI, ...JSON.parse(raw) }
if (raw) {
const ui = { ...DEFAULT_UI, ...JSON.parse(raw) } as UIState
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
return ui
}
} catch { /* ignore */ }
return { ...DEFAULT_UI }
}
@@ -115,7 +127,7 @@ let state: AppState = {
contextObs: {},
projects: [], currentProjectId: null, tasks: [], me: null, currentConvId: null,
messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null,
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', profiles: [], pluginPanels: [],
plan: null, todos: null, artifacts: [], cards: [], projectCards: [], inboxScope: 'conv', runtimeMock: false, profiles: [], pluginPanels: [],
serverWorkspaces: [],
ui: loadUI(), connected: false, error: null, toast: null,
outbox: [], outboxDraining: false,
@@ -152,8 +164,37 @@ function setUI(patch: Partial<UIState>) {
listeners.forEach(l => l())
}
let systemQuery: MediaQueryList | null = null
let systemHandler: (() => void) | null = null
function applyTheme(theme: string) {
document.documentElement.dataset.theme = theme
if (typeof document === 'undefined') return
if (systemQuery && systemHandler) {
systemQuery.removeEventListener('change', systemHandler)
systemQuery = null
systemHandler = null
}
const paint = (prefersDark: boolean) => {
document.documentElement.dataset.theme = resolveTheme(theme, prefersDark)
}
if (theme === 'system' && typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
systemQuery = window.matchMedia('(prefers-color-scheme: dark)')
systemHandler = () => paint(systemQuery?.matches ?? false)
systemQuery.addEventListener('change', systemHandler)
paint(systemQuery.matches)
return
}
paint(false)
}
applyTheme(state.ui.theme)
function flashLocate(root: string, attr: string, id: string) {
if (typeof document === 'undefined') return
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(id) : id.replace(/["\\]/g, '')
const el = document.querySelector(`${root} [${attr}="${esc}"]`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
flashElement(el)
}
let sse: { close: () => void } | null = null
@@ -527,6 +568,10 @@ export const store = {
const out = await api.cardAction(card.id, actionId, freeText)
store.upsertCard(out.card)
if (out.receipt) set({ messages: [...state.messages, out.receipt as Message] })
// 命令确认卡的动作响应是决议前快照。再拉一次,内联卡才能从待确认过渡到折叠。
if (card.kind === 'confirm' && actionId !== 'recheck' && out.card.status === 'action_required' && card.conversationId) {
await store.refreshCards(card.conversationId)
}
} catch (e) {
store.toast(`操作失败:${e}`)
}
@@ -561,6 +606,12 @@ export const store = {
const ws = await api.listWorkspaces()
set({ serverWorkspaces: ws ?? [] })
} catch { /* ignore */ }
try {
const settings = await api.getSettings()
const runtime = settings.user.runtime
const mock = !!runtime && typeof runtime === 'object' && (runtime as { mock?: unknown }).mock === true
set({ runtimeMock: mock })
} catch { /* ignore */ }
},
async uploadAttachment(file: File): Promise<string | null> {
@@ -655,18 +706,21 @@ export const store = {
if (done.length > 0) store.toast(`已收纳 ${done.length} 张完成卡`)
},
closeWorkspace() { setUI({ activeWorkspace: null }) },
/** 铃铛:打开右栏并切到待处理。哪一侧有待办就看哪一侧。 */
focusPending() {
const conv = state.cards.filter(c => c.status === 'action_required').length
const proj = state.projectCards.filter(c => c.status === 'action_required').length
if (conv === 0 && proj > 0) store.setInboxScope('project')
setUI({ rightCollapsed: false, rightTab: 'inbox', inboxFilter: 'todo' })
},
openInbox(cardId?: string) {
setUI({ rightCollapsed: false, rightTab: 'inbox' })
if (cardId) setTimeout(() => {
document.querySelector(`[data-card-id="${cardId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
},
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
locateMessage(messageId?: string) {
if (!messageId) return
setTimeout(() => {
document.querySelector(`[data-msg-id="${messageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, 60)
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
},
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
+61 -3
View File
@@ -15,8 +15,39 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 2px; }
/* ---- 面板化三栏:独立圆角面板 + 4px 间隙(DESIGN.md §6) ---- */
.app { display: flex; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.left-nav { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
.app { display: flex; flex-direction: column; height: 100vh; overflow: hidden; gap: 4px; padding: 4px; background: var(--bg-inset); }
.app-body { display: flex; flex: 1; min-height: 0; gap: 4px; }
.topbar {
display: flex; align-items: center; gap: 10px; height: 40px; flex-shrink: 0;
padding: 0 12px; border-radius: var(--radius-lg);
background: var(--bg-sidebar); color: var(--text-sidebar);
border: 1px solid var(--border-sidebar);
}
.topbar-name { font-weight: 650; letter-spacing: .01em; }
.topbar-project { color: var(--text-sidebar-dim); font-size: var(--fs-ui-sm); max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topbar-stage {
font-size: var(--fs-ui-xs); padding: 2px 8px; border-radius: var(--radius-full);
border: 1px solid var(--border-sidebar); color: var(--text-sidebar);
}
.topbar-spacer { flex: 1; }
.topbar-bell {
position: relative; width: 28px; height: 28px; display: inline-flex; align-items: center; justify-content: center;
background: none; border: 1px solid var(--border-sidebar); border-radius: var(--radius-sm);
color: inherit; cursor: pointer;
}
.topbar-bell:hover { background: var(--bg-hover); }
.left-nav { background: var(--bg-sidebar); color: var(--text-sidebar); border: 1px solid var(--border-sidebar); border-radius: var(--radius-lg); display: flex; flex-direction: column; min-width: 0; flex-shrink: 0; transition: width .15s var(--ease); }
[data-theme='molv'] .left-nav,
[data-theme='molv'] .topbar {
--text: var(--text-sidebar);
--text-dim: var(--text-sidebar-dim);
--text-faint: var(--text-sidebar-faint);
--bg-hover: rgba(233, 240, 246, 0.08);
--bg-active: rgba(233, 240, 246, 0.14);
--border: var(--border-sidebar);
--border-soft: var(--border-sidebar);
--border-strong: var(--border-sidebar);
}
.left-nav.collapsed { width: 52px !important; }
.divider { width: 4px; cursor: col-resize; background: transparent; flex-shrink: 0; position: relative; }
.divider::before { content: ''; position: absolute; left: 1px; top: 12px; bottom: 12px; width: 2px; border-radius: 2px; background: transparent; transition: background .12s; }
@@ -57,10 +88,16 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
/* ---- 中栏对话 ---- */
/* min-height:0 须随列向 flex 链下传(.main→.chat→.chat-body):flex 项的自动最小
高度是内容高度,缺失时消息会撑破 .chat——chat-body 永不滚动、输入栏被顶出视口。 */
.chat { flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat { position: relative; flex: 1; display: flex; flex-direction: column; min-width: 0; min-height: 0; }
.chat-header { padding: 12px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; gap: 10px; background: var(--bg); border-radius: var(--radius-lg) var(--radius-lg) 0 0; }
.chat-title { font-weight: 600; font-size: var(--fs-ui-base); letter-spacing: -0.01em; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chat-body { flex: 1; overflow-y: auto; min-height: 0; padding: 22px max(22px, 13%); }
.drop-veil {
position: absolute; inset: 8px; z-index: 5; pointer-events: none;
display: flex; align-items: center; justify-content: center;
border: 1px dashed var(--accent); border-radius: var(--radius-lg);
background: var(--accent-soft); color: var(--accent); font-size: var(--fs-ui-sm);
}
.empty-state { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-faint); gap: 10px; font-size: var(--fs-ui-caption); }
.empty-state .es-mark { width: 44px; height: 44px; border-radius: var(--radius-lg); background: var(--bg-raised); border: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: center; color: var(--text-faint); margin-bottom: 4px; }
.empty-state svg { opacity: .5; }
@@ -476,3 +513,24 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
border-radius: var(--radius-full); padding: 2px 12px; font-size: var(--fs-ui-sm); cursor: pointer; opacity: .72;
}
.inbox-scope button.active { opacity: 1; font-weight: 600; background: var(--bg-hover); }
.tsb-project {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%;
text-align: left; background: none; border: none; cursor: pointer;
padding: 8px 10px; border-radius: var(--radius); color: inherit;
}
.tsb-project:hover { background: var(--bg-hover); }
.tsb-project.active { background: var(--bg-active); }
.tsb-project-name { font-weight: 600; font-size: var(--fs-ui-sm); }
.tsb-project-meta { color: var(--text-faint); font-size: var(--fs-ui-xs); }
.tsb-tool {
display: flex; align-items: center; gap: 8px; width: 100%;
background: none; border: none; cursor: pointer; color: inherit;
padding: 6px 10px; border-radius: var(--radius); font-size: var(--fs-ui-sm);
}
.tsb-tool:hover { background: var(--bg-hover); }
.tsb-tool kbd { margin-left: auto; font-family: var(--mono); font-size: var(--fs-ui-xs); color: var(--text-faint); }
.tsb-tool-note { color: var(--text-faint); font-size: var(--fs-ui-xs); padding: 2px 10px 8px; }
.tsb-tool-badge {
margin-left: 6px; min-width: 16px; padding: 0 5px; border-radius: var(--radius-full);
background: var(--err); color: var(--text-on-accent); font-size: var(--fs-ui-xs);
}
+13
View File
@@ -0,0 +1,13 @@
// 主题选择:深色 / 浅色 / 墨绿直接上屏;跟随系统只在深色与浅色之间解析。
export type ThemeChoice = 'dark' | 'light' | 'molv' | 'system'
export type PaintedTheme = 'dark' | 'light' | 'molv'
export function resolveTheme(choice: string, prefersDark: boolean): PaintedTheme {
if (choice === 'system') return prefersDark ? 'dark' : 'light'
if (choice === 'light' || choice === 'molv') return choice
return 'dark'
}
export function isThemeChoice(value: string): value is ThemeChoice {
return value === 'dark' || value === 'light' || value === 'molv' || value === 'system'
}
+66
View File
@@ -0,0 +1,66 @@
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
// 内置「工件」「代理」补进空位。插件管理面板不进这条清单。
export interface WorkspaceEntry {
key: string
title: string
icon: string
shortcut: string
}
export interface WorkspaceSource {
id: string
title: string
icon?: string
shortcut?: string
}
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
{ key: 'artifacts', title: '工件', icon: '📦' },
{ key: 'agents', title: '代理', icon: '🤖' },
]
function shortcutNum(raw?: string): number | null {
if (!raw) return null
const m = raw.match(/(\d)/)
if (!m) return null
const n = Number(m[1])
return n >= 1 && n <= 9 ? n : null
}
export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry[] {
const used = new Set<number>()
const ranked: { rank: number; entry: Omit<WorkspaceEntry, 'shortcut'> }[] = []
const unranked: Omit<WorkspaceEntry, 'shortcut'>[] = []
for (const w of server) {
const entry = { key: `ws:${w.id}`, title: w.title, icon: w.icon || '🗂' }
const n = shortcutNum(w.shortcut)
if (n && !used.has(n)) {
used.add(n)
ranked.push({ rank: n, entry })
} else {
unranked.push(entry)
}
}
const takeFree = (prefer: number): number => {
if (!used.has(prefer) && prefer >= 1 && prefer <= 9) {
used.add(prefer)
return prefer
}
for (let n = 1; n <= 9; n++) {
if (!used.has(n)) {
used.add(n)
return n
}
}
return 99
}
ranked.push({ rank: takeFree(1), entry: BUILTINS[0] })
ranked.push({ rank: takeFree(2), entry: BUILTINS[1] })
for (const entry of unranked) ranked.push({ rank: takeFree(9), entry })
return ranked
.filter(item => item.rank <= 9)
.sort((a, b) => a.rank - b.rank)
.slice(0, 9)
.map((item, i) => ({ ...item.entry, shortcut: `⌘${i + 1}` }))
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { scrollRatio, scrollTopForRatio } from '../src/scrollsync'
describe('scrollsync', () => {
it('没有溢出时不同步', () => {
expect(scrollRatio({ scrollTop: 0, scrollHeight: 100, clientHeight: 100 })).toBeNull()
expect(scrollTopForRatio({ scrollHeight: 80, clientHeight: 80 }, 0.5)).toBeNull()
})
it('按可滚动范围的比例映射,并夹在 0 到 1', () => {
expect(scrollRatio({ scrollTop: 50, scrollHeight: 200, clientHeight: 100 })).toBe(0.5)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 0.5)).toBe(100)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, 2)).toBe(200)
expect(scrollTopForRatio({ scrollHeight: 300, clientHeight: 100 }, -1)).toBe(0)
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { projectStage } from '../src/projectmeta'
import { resolveTheme } from '../src/theme'
describe('resolveTheme', () => {
it('跟随系统只在深色与浅色之间解析', () => {
expect(resolveTheme('system', true)).toBe('dark')
expect(resolveTheme('system', false)).toBe('light')
})
it('墨绿与浅色原样上屏,未知值回落深色', () => {
expect(resolveTheme('molv', true)).toBe('molv')
expect(resolveTheme('light', true)).toBe('light')
expect(resolveTheme('nope', false)).toBe('dark')
})
})
describe('projectStage', () => {
it('只在 settings.stage 是非空字符串时显示', () => {
expect(projectStage({ stage: '设计输入' })).toBe('设计输入')
expect(projectStage({ stage: ' ' })).toBeNull()
expect(projectStage({ stage: 3 })).toBeNull()
expect(projectStage(undefined)).toBeNull()
})
})
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { buildWorkspaceEntries } from '../src/workspaces'
describe('buildWorkspaceEntries', () => {
it('没有领域插件时,工件和代理补在 ⌘1 ⌘2', () => {
const entries = buildWorkspaceEntries([])
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 工件', '⌘2 代理'])
})
it('声明了 shortcut 的工作区占住该位,内置工具让位', () => {
const entries = buildWorkspaceEntries([
{ id: 'trace', title: '追溯网', shortcut: '1' },
])
expect(entries.map(e => e.title)).toEqual(['追溯网', '工件', '代理'])
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2', '⌘3'])
expect(entries[0].key).toBe('ws:trace')
})
it('未编号的工作区排在内置工具后面', () => {
const entries = buildWorkspaceEntries([
{ id: 'mem', title: '记忆库' },
])
expect(entries.map(e => e.title)).toEqual(['工件', '代理', '记忆库'])
})
})
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/pluginHost.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/store.ts","./src/tasklist.ts","./src/toolkind.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/notice.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/tasklist.test.ts","./test/toolkind.test.ts"],"version":"5.9.3"}
{"root":["./src/App.tsx","./src/api.ts","./src/auth.ts","./src/contracts.ts","./src/live.ts","./src/main.tsx","./src/messageparts.ts","./src/outbox.ts","./src/passwords.ts","./src/pluginHost.ts","./src/previewsetup.ts","./src/previewtabs.ts","./src/projectmeta.ts","./src/scrollsync.ts","./src/store.ts","./src/tasklist.ts","./src/theme.ts","./src/toolkind.ts","./src/workspaces.ts","./src/components/AdminUsersModal.tsx","./src/components/AdminZone.tsx","./src/components/AgentTracker.tsx","./src/components/ArtifactsPanel.tsx","./src/components/Conversation.tsx","./src/components/DocViewer.tsx","./src/components/LoginPage.tsx","./src/components/PreviewPane.tsx","./src/components/ProcessTimeline.tsx","./src/components/ProjectSettingsModal.tsx","./src/components/SettingsModal.tsx","./src/components/TaskSidebar.tsx","./src/components/TopBar.tsx","./src/components/Workbench.tsx","./src/components/Workspaces.tsx","./src/components/adminSections.tsx","./test/auth.test.ts","./test/contracts.test.ts","./test/design-guard.test.ts","./test/live.test.ts","./test/messageparts.test.ts","./test/notice.test.ts","./test/outbox.test.ts","./test/previewtabs.test.ts","./test/scrollsync.test.ts","./test/tasklist.test.ts","./test/theme.test.ts","./test/toolkind.test.ts","./test/workspaces.test.ts"],"version":"5.9.3"}