Rebuild the workbench shell around projects and card flow
Why: The first S4 pass makes the project the navigation context, keeps deep views in one overlay, and leaves domain panels empty until a plugin declares them. Evidence: npx tsc -b and npm test (61 passed) in modules/frontend. Impact: Top bar, project sidebar, inbox all/todo filter, one-way scroll sync, drag-and-drop on the whole chat column, and local molv/system themes. Refs: feature/s4-shell-overhaul Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
-1
@@ -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 />}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
+60
-10
@@ -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
|
||||
@@ -561,6 +602,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 +702,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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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}` }))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user