+
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 &&
释放以上传资料
}
{projectName}
{conversation?.title ?? '会话'}
@@ -432,14 +454,16 @@ export function Conversation() {
{ 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 &&
发送消息开始对话
-
mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help
+ {runtimeMock &&
mock 模式演示指令:#hitl · #agents · #doc · #error · 命令:/help
}
}
diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx
index 78bd2ab..713a896 100644
--- a/src/components/SettingsModal.tsx
+++ b/src/components/SettingsModal.tsx
@@ -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
>({})
const [draft, setDraft] = useState>({})
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' && (
+
+
+
+
+
+ )}
+ {section === 'settings' && Object.entries(groups).map(([g, descs]) => {
+ const rows = descs.filter(d => d.key !== 'ui.theme')
+ if (rows.length === 0) return null
+ return (
- {descs.map(d => (
+ {rows.map(d => (
setDraft(x => ({ ...x, [d.key]: v }))} />
))}
- ))}
+ )
+ })}
)
}
diff --git a/src/components/TaskSidebar.tsx b/src/components/TaskSidebar.tsx
index b944cab..591f600 100644
--- a/src/components/TaskSidebar.tsx
+++ b/src/components/TaskSidebar.tsx
@@ -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 {
- 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>(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() {
)
- const taskRow = (t: Conversation, badge?: string) => (
+ const taskRow = (t: Conversation) => (
store.openTask(t.id)}
onContextMenu={e => { e.preventDefault(); setMenu({ kind: `row:${t.id}`, anchor: e.currentTarget as HTMLElement }) }}>
{t.title || '未命名任务'}
{t.crossPrivate && }
- {badge && {badge}}
{relativeTime(t.updatedAt)}
{rowActions(t)}
@@ -117,8 +100,7 @@ export function TaskSidebar() {
return (
<>
-
- Agent-System
+ 项目
@@ -138,12 +120,9 @@ export function TaskSidebar() {
- {/* 组织视图切换(P8)+ 过滤 */}
- {org === 'project' ? (
-
tglGroup(g.key)}>
-
- {g.label}
- {g.tasks.length}
-
-
-
-
- ) : (
-
{g.label}
- )}
- {!collapsed[g.key] && g.tasks.map(t => taskRow(t, org === 'timeline' ? projects.find(p => p.id === t.projectId)?.name : undefined))}
-
+
-
+
+
+
+
+
- ) : (
-
-
-
-
-
-
- {inboxScope === 'project'
- ?
- :
}
-
- )}
+ {inboxScope === 'project'
+ ?
+ :
}
+
>
)
}
diff --git a/src/components/Workspaces.tsx b/src/components/Workspaces.tsx
index 2d85ee7..e9cda44 100644
--- a/src/components/Workspaces.tsx
+++ b/src/components/Workspaces.tsx
@@ -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
+ return
}
if (active === 'agents') {
const convRuns = runs.filter(r => r.kind !== 'tool')
- return
+ return
}
@@ -51,7 +41,8 @@ export function Workspaces() {
}
if (active.startsWith('ws:')) {
const ws = serverWorkspaces.find(w => `ws:${w.id}` === active)
- return store.refreshPlugins()} />
}
return null
diff --git a/src/projectmeta.ts b/src/projectmeta.ts
new file mode 100644
index 0000000..5c24aea
--- /dev/null
+++ b/src/projectmeta.ts
@@ -0,0 +1,5 @@
+/** 阶段徽章:只读项目设置里的 stage 字符串;没有就不显示。 */
+export function projectStage(settings?: Record | null): string | null {
+ const stage = settings?.stage
+ return typeof stage === 'string' && stage.trim() !== '' ? stage.trim() : null
+}
diff --git a/src/scrollsync.ts b/src/scrollsync.ts
new file mode 100644
index 0000000..b3f50c2
--- /dev/null
+++ b/src/scrollsync.ts
@@ -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, 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)
+}
diff --git a/src/store.ts b/src/store.ts
index a6466c5..9d5144c 100644
--- a/src/store.ts
+++ b/src/store.ts
@@ -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) {
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 {
@@ -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 }) },
diff --git a/src/styles.css b/src/styles.css
index 679f4f8..963105b 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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);
+}
diff --git a/src/theme.ts b/src/theme.ts
new file mode 100644
index 0000000..d1b69cd
--- /dev/null
+++ b/src/theme.ts
@@ -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'
+}
diff --git a/src/workspaces.ts b/src/workspaces.ts
new file mode 100644
index 0000000..0f16af4
--- /dev/null
+++ b/src/workspaces.ts
@@ -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[] = [
+ { 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()
+ const ranked: { rank: number; entry: Omit }[] = []
+ const unranked: Omit[] = []
+ 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}` }))
+}
diff --git a/test/scrollsync.test.ts b/test/scrollsync.test.ts
new file mode 100644
index 0000000..9c7ddf7
--- /dev/null
+++ b/test/scrollsync.test.ts
@@ -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)
+ })
+})
diff --git a/test/theme.test.ts b/test/theme.test.ts
new file mode 100644
index 0000000..d6844cc
--- /dev/null
+++ b/test/theme.test.ts
@@ -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()
+ })
+})
diff --git a/test/workspaces.test.ts b/test/workspaces.test.ts
new file mode 100644
index 0000000..ad11aa0
--- /dev/null
+++ b/test/workspaces.test.ts
@@ -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(['工件', '代理', '记忆库'])
+ })
+})
diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo
index 8092e0a..46cd36c 100644
--- a/tsconfig.tsbuildinfo
+++ b/tsconfig.tsbuildinfo
@@ -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"}
\ No newline at end of file
+{"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"}
\ No newline at end of file