diff --git a/src/App.tsx b/src/App.tsx index 37f1b3b..4f1c551 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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

连接服务中…

} + const tools = workspaceEntries() return (
+ +
{!ui.leftCollapsed ? ( <>
@@ -127,9 +132,12 @@ export function App() {
- + {tools.map(entry => ( + + ))}
@@ -164,6 +172,7 @@ export function App() {
)} + {trackerRunId && } diff --git a/src/components/Conversation.tsx b/src/components/Conversation.tsx index 92a99cd..4b7f21c 100644 --- a/src/components/Conversation.tsx +++ b/src/components/Conversation.tsx @@ -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([]) const [attachmentNames, setAttachmentNames] = useState([]) + const [dragDepth, setDragDepth] = useState(0) const bodyRef = useRef(null) artifactCache = Object.fromEntries(artifacts.map(a => [a.id, a])) @@ -401,14 +408,29 @@ export function Conversation() {
⌘
选择或创建一个会话开始
-
mock 演示模式可在 设置 → runtime 开启,无需模型密钥
+ {runtimeMock + ?
mock 模式演示指令:#hitl · #agents · #doc · #error
+ :
演示模式可在设置里打开,无需模型密钥
}
) } return ( -
+
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)+ 过滤 */}
-
- - -
+ 本项目会话 +
@@ -164,34 +143,55 @@ export function TaskSidebar() { )} +
项目
+ {projects.map(p => { + const stage = projectStage(p.settings) + const count = tasks.filter(t => t.projectId === p.id && !t.archived).length + return ( + + ) + })} + {projects.length === 0 &&
还没有项目——从「新建任务」旁的菜单创建
} + {pinned.length > 0 && ( <>
置顶
- {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 && ( -
尚无任务——点击「新建任务」开始
(项目内素材上传、检索与生成全程可在对话中完成)
+ {rest.length === 0 && pinned.length === 0 && ( +
这个项目还没有会话——点击「新建任务」开始
)} + {rest.map(t => taskRow(t))} - {groups.map(g => ( -
- {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))} -
+
+ 体系工具 + {pendingBadge > 0 && {pendingBadge}} +
+ {tools.map(entry => ( + ))} + {serverWorkspaces.length === 0 &&
领域工具由系统级插件提供
} + {currentProject && ( + + )} {/* 账户区(P7):身份常驻;阶段 1 = API key 即身份(user 即租户 owner) */} @@ -205,7 +205,7 @@ export function TaskSidebar() { - {pendingBadge > 0 && } + {pendingBadge > 0 && } {/* 弹出菜单们 */} @@ -226,7 +226,7 @@ export function TaskSidebar() { { store.setCrossFlags(rowTask.id, { crossPrivate: !rowTask.crossPrivate }); setMenu(null) }}>{rowTask.crossPrivate ? '取消私有(可被其他会话读取)' : '设为私有(不被其他会话读取)'} { store.setCrossFlags(rowTask.id, { allowCrossRead: rowTask.allowCrossRead === false }); setMenu(null) }}>{rowTask.allowCrossRead === false ? '开启读取其他会话' : '关闭读取其他会话'}
- { store.setUI({ taskOrg: 'timeline' }); setMenu(null) }}>按时间线查看 + { if (rowTask) store.openProjectSettings(rowTask.projectId); setMenu(null) }}>项目设置 } @@ -240,11 +240,12 @@ export function TaskSidebar() {
{ store.openSettings(true); setMenu(null) }}>偏好设置 - { store.openInbox(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''} + { store.focusPending(); setMenu(null) }}>收件箱{pendingBadge > 0 ? `(待办 ${pendingBadge})` : ''} { store.setUI({ changePwOpen: true }); setMenu(null) }}>修改密码… - { store.setUI({ theme: theme === 'dark' ? 'light' : 'dark' }); setMenu(null) }}> - {theme === 'dark' ? '切换浅色主题' : '切换深色主题'} - + { store.setUI({ theme: 'dark' }); setMenu(null) }}>深色主题{theme === 'dark' ? ' · 当前' : ''} + { store.setUI({ theme: 'light' }); setMenu(null) }}>浅色主题{theme === 'light' ? ' · 当前' : ''} + { store.setUI({ theme: 'molv' }); setMenu(null) }}>墨绿主题{theme === 'molv' ? ' · 当前' : ''} + { store.setUI({ theme: 'system' }); setMenu(null) }}>跟随系统{theme === 'system' ? ' · 当前' : ''} {me?.isAdmin && { store.setUI({ adminZoneOpen: true }); setMenu(null) }}>系统管理…} {me?.auth && <>
diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx new file mode 100644 index 0000000..7363502 --- /dev/null +++ b/src/components/TopBar.tsx @@ -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 ( +
+ + Agent-System + {project && {project.name}} + {stage && {stage}} + + +
+ ) +} diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx index 9d8549d..80dd77b 100644 --- a/src/components/Workbench.tsx +++ b/src/components/Workbench.tsx @@ -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[] }) { <>
📁 项目级卡片(跨会话可见)
{own.length > 0 - ? + ? :
暂无——会话卡点 🗂 即留档至此
} {cross.length > 0 && ( <>
🕓 其他会话待办
- + )} @@ -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 ( <>
- - + +
- {showFiles ? ( -
- +
+ +
+ +
- ) : ( -
- -
- - -
- {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..314f7d5 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 @@ -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 { @@ -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 }) }, 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