Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4920b509 | ||
|
|
a26d7c5aee | ||
|
|
69b1487fb7 | ||
|
|
32e327a831 | ||
|
|
3ed10e1220 | ||
|
|
204ca67dc7 | ||
|
|
339a9ebc2d |
+27
-29
@@ -8,7 +8,6 @@ import { Icon } from '@ui'
|
||||
import { store, useStore } from './store'
|
||||
import { TaskSidebar } from './components/TaskSidebar'
|
||||
import { Conversation } from './components/Conversation'
|
||||
import { PreviewPane } from './components/PreviewPane'
|
||||
import { previewTabsKey } from './previewtabs'
|
||||
import { Workbench } from './components/Workbench'
|
||||
import { Workspaces, workspaceShortcuts } from './components/Workspaces'
|
||||
@@ -18,7 +17,8 @@ import { ProjectSettingsModal } from './components/ProjectSettingsModal'
|
||||
import { TopBar } from './components/TopBar'
|
||||
import { workspaceEntries } from './components/Workspaces'
|
||||
|
||||
// 布局:左(项目会话)/ 中(对话)/ 预览区(有标签时)/ 右(工作台)。
|
||||
// 布局(三栏工作台):左(项目与会话)/ 中(对话)/ 第三栏(工作|文件双入口)。
|
||||
// 文件预览不再是独立第四栏——预览在第三栏「文件」入口内,展开态铺满主区。
|
||||
const LEFT_MIN = 220, LEFT_MAX = 420, LEFT_OVERSHOOT = 40
|
||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
@@ -32,11 +32,15 @@ export function App() {
|
||||
|
||||
const ui = useStore(s => s.ui)
|
||||
// 预览键与 requestPreview 的落点一致(previewTabsKey):空态(未选会话)点开
|
||||
// 工件也要渲染预览区,否则点击只剩工件弹层关闭、毫无反馈。
|
||||
// 工件也要能进文件入口,否则点击只剩工件弹层关闭、毫无反馈。
|
||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const toast = useStore(s => s.toast)
|
||||
const trackerRunId = useStore(s => s.ui.trackerRunId)
|
||||
const previewExpanded = previewOpen && ui.previewExpanded
|
||||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(420–1100),不受工作档上限约束。
|
||||
const filesWide = rightTab === 'files' && previewOpen
|
||||
const rightPanelWidth = filesWide ? ui.previewWidth : ui.rightWidth
|
||||
const previewExpanded = filesWide && ui.previewExpanded
|
||||
const pendingBadge = useStore(s => s.cards.filter(c => c.status === 'action_required').length)
|
||||
// authState:checking → login(登录)→ mustchange(首登强制改密)→ ok(放行)。
|
||||
const [authState, setAuthState] = useState<'checking' | 'login' | 'mustchange' | 'ok'>('checking')
|
||||
@@ -64,28 +68,31 @@ export function App() {
|
||||
store.refreshPlugins()
|
||||
}, [authState])
|
||||
|
||||
const drag = useRef<{ side: 'left' | 'right' | 'preview'; startX: number; startW: number; overshoot: boolean } | null>(null)
|
||||
const drag = useRef<{ side: 'left' | 'right'; startX: number; startW: number; overshoot: boolean; min: number; max: number; key: 'leftWidth' | 'rightWidth' | 'previewWidth' } | null>(null)
|
||||
|
||||
const onDividerDown = (side: 'left' | 'right' | 'preview') => (e: React.MouseEvent) => {
|
||||
const onDividerDown = (side: 'left' | 'right') => (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startW = side === 'left' ? ui.leftWidth : side === 'preview' ? ui.previewWidth : ui.rightWidth
|
||||
drag.current = { side, startX: e.clientX, startW, overshoot: false }
|
||||
// 右侧分隔条按当前入口选宽度档:文件+预览在场走阅读档,否则工作档。
|
||||
const wide = side === 'right' && rightTab === 'files' && previewOpen
|
||||
const key = side === 'left' ? 'leftWidth' : wide ? 'previewWidth' : 'rightWidth'
|
||||
drag.current = {
|
||||
side, startX: e.clientX, startW: ui[key], overshoot: false,
|
||||
min: side === 'left' ? LEFT_MIN : wide ? PREVIEW_MIN : RIGHT_MIN,
|
||||
max: side === 'left' ? LEFT_MAX : wide ? PREVIEW_MAX : RIGHT_MAX,
|
||||
key,
|
||||
}
|
||||
const move = (ev: MouseEvent) => {
|
||||
const d = drag.current
|
||||
if (!d) return
|
||||
if (d.side === 'left') {
|
||||
const w = d.startW + (ev.clientX - d.startX)
|
||||
if (w < LEFT_MIN - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||||
if (w < d.min - LEFT_OVERSHOOT) { d.overshoot = true; return }
|
||||
d.overshoot = false
|
||||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(LEFT_MAX, Math.max(LEFT_MIN, w)) })
|
||||
} else if (d.side === 'preview') {
|
||||
const w = d.startW - (ev.clientX - d.startX)
|
||||
if (w < PREVIEW_MIN) return
|
||||
store.setUI({ previewWidth: Math.min(PREVIEW_MAX, w) })
|
||||
store.setUI({ leftCollapsed: false, leftWidth: Math.min(d.max, Math.max(d.min, w)) })
|
||||
} else {
|
||||
const w = d.startW - (ev.clientX - d.startX)
|
||||
if (w < RIGHT_MIN) return
|
||||
store.setUI({ rightCollapsed: false, rightWidth: Math.min(RIGHT_MAX, w) })
|
||||
if (w < d.min) return
|
||||
store.setUI({ rightCollapsed: false, [d.key]: Math.min(d.max, w) } as Partial<typeof ui>)
|
||||
}
|
||||
}
|
||||
const up = () => {
|
||||
@@ -146,30 +153,21 @@ export function App() {
|
||||
<Conversation />
|
||||
</div>
|
||||
|
||||
{previewOpen && (
|
||||
<>
|
||||
{!previewExpanded && (
|
||||
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
|
||||
)}
|
||||
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
|
||||
<PreviewPane />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!ui.rightCollapsed ? (
|
||||
<>
|
||||
<div className="divider" onMouseDown={onDividerDown('right')} onDoubleClick={store.toggleRight} title="拖动调整宽度;双击收起" />
|
||||
<div className="right-panel" style={{ width: ui.rightWidth }}>
|
||||
<div className={`right-panel mk-panel-grow${previewExpanded ? ' expanded' : ''}`}
|
||||
style={previewExpanded ? undefined : { width: rightPanelWidth }}>
|
||||
<Workbench />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="right-panel collapsed" style={{ width: 44 }}>
|
||||
<div className="icon-only">
|
||||
<button title="展开收件箱" onClick={store.toggleRight}>◧
|
||||
<button title="展开右栏" onClick={store.toggleRight}>◧
|
||||
{pendingBadge > 0 && <span className="rail-badge" data-testid="rail-badge">{pendingBadge}</span>}
|
||||
</button>
|
||||
<button title="文件(搜索、预览、编辑、下载)" onClick={() => store.openFiles()}><Icon name="folder" size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 工件面板(工作区文件管理):搜索 + kind 过滤 + 上传;行内打开预览区或下载。
|
||||
// ⌘1 弹层与右栏「文件」页签共用这一份。删除仍是保留能力,系统默认不提供入口。
|
||||
// 文件列表(第三栏「文件」入口的主视图):搜索 + kind 过滤 + 上传;
|
||||
// 行内打开预览(同入口切到预览子视图)或下载。删除仍是保留能力,系统默认不提供入口。
|
||||
import { useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
import { api } from '../api'
|
||||
@@ -37,16 +37,16 @@ export function ArtifactsPanel() {
|
||||
: visible.length === 0
|
||||
? <div className="empty-note">没有匹配「{q}」的文件</div>
|
||||
: visible.map(a => (
|
||||
// 预览落在对话右侧预览区。工作区弹层盖住主区,先收起再打开。
|
||||
// 预览落位在第三栏「文件」入口:requestPreview 会切到预览子视图。
|
||||
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
|
||||
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
|
||||
onClick={() => store.requestPreview(a)} title={`预览 ${a.name}`}>
|
||||
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
|
||||
<span className="ar-main">
|
||||
<span className="name">{a.name}</span>
|
||||
<span className="meta">{a.kind} · {humanSize(a.size) || '—'} · {shortTime(a.updatedAt)}</span>
|
||||
</span>
|
||||
<span className="ar-acts">
|
||||
<button className="icon-btn" title="展开预览" onClick={e => { e.stopPropagation(); store.closeWorkspace(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<button className="icon-btn" title="展开预览(铺满主区)" onClick={e => { e.stopPropagation(); store.requestPreview(a); store.setUI({ previewExpanded: true }) }}><Icon name="maximize" size={14} /></button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(a.id)} download={a.name}
|
||||
onClick={e => e.stopPropagation()}><Icon name="download" size={14} /></a>
|
||||
</span>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { Icon, Avatar, BrandMark, Collapsible, getPersistedOpen, setPersistedOpen, SafeMarkdown } from '@ui'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore, activeRun, type LiveState, type ContextObs } from '../store'
|
||||
import { store, useStore, activeRun, isTerminal, type LiveState, type ContextObs } from '../store'
|
||||
import { scrollRatio, scrollTopForRatio } from '../scrollsync'
|
||||
import { liveToMessage, livePhaseLabel, showProcessInline } from '../live'
|
||||
import { deriveShellPhase, isShellBusy, showLiveBubble, type ShellPhase } from '../runstate'
|
||||
@@ -484,6 +484,8 @@ export function Conversation() {
|
||||
})
|
||||
const busy = isShellBusy(phase)
|
||||
const showStop = phase === 'running'
|
||||
// 慢通道在跑的后台子代理:前台回合结束后仍在执行,完成后经通知回到对话汇报。
|
||||
const bgRunning = runs.filter(r => r.kind === 'subagent' && !isTerminal(r.status)).length
|
||||
|
||||
useEffect(() => {
|
||||
if (!stickRef.current) return
|
||||
@@ -553,7 +555,6 @@ export function Conversation() {
|
||||
<SessionNoteField convId={currentConvId} note={conversation?.sessionNote ?? ''} />
|
||||
<span title={connected ? '事件流已连接' : '重连中'} className={`conn-dot ${connected ? 'on' : 'off'}`} />
|
||||
{!connected && <span className="conn-label">重连中</span>}
|
||||
{showStop && <button className="btn ghost" onClick={() => store.cancelActive()}>停止</button>}
|
||||
</div>
|
||||
|
||||
<div className="chat-body" ref={bodyRef}
|
||||
@@ -615,6 +616,19 @@ export function Conversation() {
|
||||
|
||||
<div className="input-bar">
|
||||
<OutboxPanel />
|
||||
{/* 轻量状态提示(方案:快慢通道连续体验):忙碌时可继续输入排队;
|
||||
后台任务在回合结束后仍在执行,完成后回到对话汇报。 */}
|
||||
{(busy || bgRunning > 0) && (
|
||||
<div className="input-status" data-testid="input-status">
|
||||
<span className="live-dot" />
|
||||
{busy
|
||||
? <span className="mk-sweep">助手工作中{bgRunning > 0 ? ` · ${bgRunning} 个后台任务` : ''}</span>
|
||||
: <span>{bgRunning} 个后台任务进行中</span>}
|
||||
<span className="input-status-note">{busy ? '输入仍可排队' : '完成后回到对话汇报'}</span>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button type="button" className="input-status-link" onClick={() => store.openInbox()}>在工作里查看</button>
|
||||
</div>
|
||||
)}
|
||||
{attachmentNames.length > 0 && (
|
||||
<div className="attachments">
|
||||
{attachmentNames.map((n, i) => <span className="attach-chip" key={i}><Icon name='paperclip' size={12} /> {n}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 对话右侧预览区:多标签、可关、展开与下载。宽度由 App 的分隔条控制;
|
||||
// 展开态铺满主区。文本类文件在这里编辑保存。
|
||||
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
|
||||
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
|
||||
import { api } from '../api'
|
||||
@@ -44,6 +44,9 @@ export function PreviewPane() {
|
||||
onClose={id => store.closePreview(id)}
|
||||
/>
|
||||
<div className="preview-pane-bar">
|
||||
<button type="button" className="icon-btn" title="返回文件列表" onClick={() => store.setUI({ filesView: 'list', previewExpanded: false })}>
|
||||
<Icon name="list" size={14} />
|
||||
</button>
|
||||
<span className="preview-pane-name" title={active.name}>{active.name}</span>
|
||||
{art && (
|
||||
<span className="preview-pane-meta">{art.kind}{humanSize(art.size) ? ` · ${humanSize(art.size)}` : ''}</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 左栏:项目列表、当前项目的会话、体系工具、账户。
|
||||
// 左栏(找上下文):顶部切换项目,中间放会话与搜索,底部放文件、工作区和账户。
|
||||
// 创建、搜索、置顶与归档仍在会话段里;领域工具由服务端工作区声明,没有时装上说明行。
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { store, useStore } from '../store'
|
||||
@@ -40,6 +40,7 @@ export function TaskSidebar() {
|
||||
const projects = useStore(s => s.projects)
|
||||
const tasks = useStore(s => s.tasks)
|
||||
const me = useStore(s => s.me)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const currentConvId = useStore(s => s.currentConvId)
|
||||
const currentProjectId = useStore(s => s.currentProjectId)
|
||||
const showArchived = useStore(s => s.ui.showArchived)
|
||||
@@ -50,7 +51,7 @@ export function TaskSidebar() {
|
||||
s.projectCards.filter(c => c.status === 'action_required').length)
|
||||
|
||||
const [q, setQ] = useState('')
|
||||
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||||
const [menu, setMenu] = useState<{ kind: 'new' | 'account' | 'project' | `row:${string}`; anchor: HTMLElement } | null>(null)
|
||||
const [creatingProject, setCreatingProject] = useState(false)
|
||||
const [newProjectName, setNewProjectName] = useState('')
|
||||
|
||||
@@ -70,6 +71,7 @@ export function TaskSidebar() {
|
||||
const pinned = visible.filter(t => t.pinned)
|
||||
const rest = visible.filter(t => !t.pinned)
|
||||
const currentProject = projects.find(p => p.id === currentProjectId)
|
||||
const stage = projectStage(currentProject?.settings)
|
||||
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
|
||||
|
||||
@@ -86,6 +88,14 @@ export function TaskSidebar() {
|
||||
</span>
|
||||
)
|
||||
|
||||
/** 切换项目:优先恢复该项目下正在看的会话,否则进第一个未归档会话。 */
|
||||
const switchProject = (pid: string) => {
|
||||
const next = tasks.find(t => t.projectId === pid && t.id === currentConvId)
|
||||
?? tasks.find(t => t.projectId === pid && !t.archived)
|
||||
if (next) store.openTask(next.id)
|
||||
else store.openProject(pid)
|
||||
}
|
||||
|
||||
const taskRow = (t: Conversation) => (
|
||||
<div key={t.id} className={`tsb-task ${t.id === currentConvId ? 'active' : ''} ${t.archived ? 'archived' : ''}`}
|
||||
onClick={() => store.openTask(t.id)}
|
||||
@@ -99,8 +109,15 @@ export function TaskSidebar() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 项目身份唯一落点:顶部切换项目(方案:项目导航置顶)。 */}
|
||||
<div className="tsb-brand">
|
||||
<span className="tsb-name">项目</span>
|
||||
<button className="tsb-projrow" title="切换项目"
|
||||
onClick={e => { const el = e.currentTarget; setMenu(m => m?.kind === 'project' ? null : { kind: 'project', anchor: el }) }}>
|
||||
<Icon name="folder" size={15} />
|
||||
<span className="tsb-projrow-name">{currentProject?.name ?? '选择项目'}</span>
|
||||
{stage && <span className="tsb-projrow-stage">{stage}</span>}
|
||||
<span className="tsb-user-chev"><Icon name="chevron-down" size={13} /></span>
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className="tsb-iconbtn" title="收起侧栏(⌘B)" onClick={store.toggleLeft}><Icon name="panel-left" /></button>
|
||||
</div>
|
||||
@@ -127,6 +144,7 @@ export function TaskSidebar() {
|
||||
onClick={() => { store.setUI({ showArchived: !showArchived }); store.refreshTasks(!showArchived) }}><Icon name="archive" size={15} /></button>
|
||||
</div>
|
||||
|
||||
{/* 中段(方案:会话与搜索)。项目切换在顶部,文件与工作区沉底。 */}
|
||||
<div className="tsb-scroll">
|
||||
{creatingProject && (
|
||||
<div className="tsb-newproject">
|
||||
@@ -143,25 +161,6 @@ 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>
|
||||
@@ -173,11 +172,15 @@ export function TaskSidebar() {
|
||||
<div className="tsb-empty">这个项目还没有会话——点击「新建任务」开始</div>
|
||||
)}
|
||||
{rest.map(t => taskRow(t))}
|
||||
</div>
|
||||
|
||||
<div className="tsb-seclabel">
|
||||
体系工具
|
||||
{pendingBadge > 0 && <span className="tsb-tool-badge" title="待处理">{pendingBadge}</span>}
|
||||
</div>
|
||||
{/* 底部(方案:文件、工作区、账户沉底)。 */}
|
||||
<div className="tsb-bottom">
|
||||
<button className="tsb-tool" title="文件搜索、预览、编辑与下载(第三栏「文件」入口)" onClick={() => store.openFiles()}>
|
||||
<span className="tsb-tool-icon"><Icon name="folder" size={14} /></span>
|
||||
<span className="tsb-tool-name">文件</span>
|
||||
{artifacts.length > 0 && <span className="tsb-group-cnt">{artifacts.length}</span>}
|
||||
</button>
|
||||
{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>
|
||||
@@ -209,6 +212,22 @@ export function TaskSidebar() {
|
||||
</div>
|
||||
|
||||
{/* 弹出菜单们 */}
|
||||
<Menu anchor={menu?.kind === 'project' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
{projects.map(p => {
|
||||
const count = tasks.filter(t => t.projectId === p.id && !t.archived).length
|
||||
const pstage = projectStage(p.settings)
|
||||
return (
|
||||
<MenuItem key={p.id} icon="folder" onClick={() => { switchProject(p.id); setMenu(null) }}>
|
||||
{p.name}
|
||||
<kbd>{p.id === currentProjectId ? '当前' : pstage ? `${pstage} · ` : ''}{count} 会话</kbd>
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
{projects.length === 0 && <div className="tsb-tool-note">还没有项目</div>}
|
||||
<div className="tsb-menu-div" />
|
||||
<MenuItem icon="plus" onClick={() => { setCreatingProject(true); setMenu(null) }}>新建项目…</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<Menu anchor={menu?.kind === 'new' ? menu.anchor : null} onClose={() => setMenu(null)}>
|
||||
<MenuItem icon="plus" onClick={() => { store.createTask(); setMenu(null) }}>新建任务 · 当前项目<kbd>⌘N</kbd></MenuItem>
|
||||
<div className="tsb-menu-div" />
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
// 顶栏:产品标识、当前项目与阶段徽章、待处理铃铛。不参与具体操作。
|
||||
// 顶栏:产品标识与待处理铃铛。项目身份在左栏顶部切换器集中显示一次(方案),
|
||||
// 阶段徽章随切换器;本栏不再重复。
|
||||
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},打开收件箱` : '收件箱'}
|
||||
<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>}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// 右栏:卡片流。全部 / 待处理筛选、收纳、本会话与项目范围。
|
||||
// 文件不占右栏页签——工件走体系工具(⌘)与预览区,一个对象一个入口。
|
||||
// 右栏(第三栏):「工作 / 文件」双入口。
|
||||
// 工作 = 当前任务卡 + 收件箱(待处理事项、后台任务卡、执行记录);
|
||||
// 文件 = 文件列表(搜索/上传)与多标签预览(预览、编辑、下载)——预览不再是独立第四栏。
|
||||
import { store, useStore } from '../store'
|
||||
import type { Run } from '../contracts'
|
||||
import { InboxList, pendingCount, type ActivityCard, type DocBridge, type InboxFilter } from '@ui'
|
||||
import { Icon } from '@ui'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { ArtifactsPanel } from './ArtifactsPanel'
|
||||
import { PreviewPane } from './PreviewPane'
|
||||
|
||||
// 工件解析/打开注入:渲染器纯展示,doc-ref 词汇经此桥接本仓状态(D29)。
|
||||
// 打开语义走 requestPreview 唯一入口(预览区)。
|
||||
// 打开语义走 requestPreview 唯一入口(第三栏「文件」入口)。
|
||||
export const docBridge: DocBridge = {
|
||||
resolveDoc: id => store.getState().artifacts.find(a => a.id === id),
|
||||
onOpenDoc: id => {
|
||||
@@ -47,6 +51,7 @@ function ProjectInbox({ cards, filter }: { cards: ActivityCard[]; filter: InboxF
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const rightTab = useStore(s => s.ui.rightTab)
|
||||
const cards = useStore(s => s.cards)
|
||||
const plan = useStore(s => s.plan)
|
||||
const todos = useStore(s => s.todos)
|
||||
@@ -55,24 +60,57 @@ export function Workbench() {
|
||||
const filter = useStore(s => s.ui.inboxFilter)
|
||||
const visible = inboxScope === 'project' ? projectCards : cards
|
||||
const pending = pendingCount(visible)
|
||||
// 离开文件入口时收回全屏展开,避免出现「工作」铺满主区的怪态。
|
||||
const switchTab = (tab: 'work' | 'files') =>
|
||||
store.setUI({ rightTab: tab, previewExpanded: tab === 'files' ? store.getState().ui.previewExpanded : false })
|
||||
return (
|
||||
<>
|
||||
<div className="tabs">
|
||||
<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>
|
||||
<button className={`tab ${rightTab === 'work' ? 'active' : ''}`} onClick={() => switchTab('work')}
|
||||
title="待处理事项、后台任务与执行记录">工作{pending > 0 ? ` ${pending}` : ''}</button>
|
||||
<button className={`tab ${rightTab === 'files' ? 'active' : ''}`} onClick={() => switchTab('files')}
|
||||
title="文件搜索、预览、编辑与下载">文件</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>
|
||||
{rightTab === 'work' && (
|
||||
<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>
|
||||
{rightTab === 'files'
|
||||
? <FilesTab />
|
||||
: <WorkTab plan={plan} todos={todos} filter={filter} inboxScope={inboxScope} projectCards={projectCards} cards={cards} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** 文件入口:有预览标签且处于预览子视图时展示预览;否则展示文件列表(搜索/上传)。 */
|
||||
function FilesTab() {
|
||||
const previewOpen = useStore(s => (s.previewTabs[previewTabsKey(s.currentConvId)]?.length ?? 0) > 0)
|
||||
const filesView = useStore(s => s.ui.filesView)
|
||||
if (previewOpen && filesView === 'preview') return <PreviewPane />
|
||||
return <div className="tab-body"><ArtifactsPanel /></div>
|
||||
}
|
||||
|
||||
function WorkTab({ plan, todos, filter, inboxScope, projectCards, cards }: {
|
||||
plan: Parameters<typeof CurrentTaskCard>[0]['plan']
|
||||
todos: Parameters<typeof CurrentTaskCard>[0]['todos']
|
||||
filter: InboxFilter
|
||||
inboxScope: 'conv' | 'project'
|
||||
projectCards: ActivityCard[]
|
||||
cards: ActivityCard[]
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="tab-body" data-scroll-sync="inbox">
|
||||
<CurrentTaskCard plan={plan} todos={todos} />
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围">
|
||||
<div className="inbox-scope" role="tablist" aria-label="收件箱范围与筛选">
|
||||
<button className={inboxScope === 'conv' ? 'active' : ''} onClick={() => store.setInboxScope('conv')}>本会话</button>
|
||||
<button className={inboxScope === 'project' ? 'active' : ''} onClick={() => store.setInboxScope('project')}>
|
||||
项目{pendingCount(projectCards) > 0 ? ` ${pendingCount(projectCards)}` : ''}
|
||||
</button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<button className={filter === 'todo' ? 'active' : ''} title="只看待处理" onClick={() => store.setUI({ inboxFilter: 'todo' })}>待处理</button>
|
||||
<button className={filter === 'all' ? 'active' : ''} title="全部卡片" onClick={() => store.setUI({ inboxFilter: 'all' })}>全部</button>
|
||||
</div>
|
||||
{inboxScope === 'project'
|
||||
? <ProjectInbox cards={projectCards} filter={filter} />
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
// 工作区弹层(提案 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'
|
||||
|
||||
/** 体系工具(顺序即 ⌘ 序号)。服务端工作区全员可见;插件管理面板只在系统管理区。 */
|
||||
@@ -15,7 +15,6 @@ export function workspaceEntries(): WorkspaceEntry[] {
|
||||
|
||||
export function Workspaces() {
|
||||
const active = useStore(s => s.ui.activeWorkspace)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const runs = useStore(s => s.runs)
|
||||
const pluginPanels = useStore(s => s.pluginPanels)
|
||||
const serverWorkspaces = useStore(s => s.serverWorkspaces)
|
||||
@@ -23,11 +22,6 @@ export function Workspaces() {
|
||||
const entry = workspaceEntries().find(e => e.key === active)
|
||||
const close = () => store.closeWorkspace()
|
||||
|
||||
if (active === 'artifacts') {
|
||||
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={entry?.shortcut ?? '⌘2'} sayHint="打开代理" onClose={close}>
|
||||
|
||||
@@ -185,6 +185,9 @@ export type UINodeType =
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
/** Surface 层节点级标注(Generative UI U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||
id?: string;
|
||||
source?: string[];
|
||||
title?: string;
|
||||
items?: string[];
|
||||
kv?: [string, string][];
|
||||
|
||||
+23
-20
@@ -43,7 +43,10 @@ export interface UIState {
|
||||
leftCollapsed: boolean
|
||||
rightWidth: number
|
||||
rightCollapsed: boolean
|
||||
rightTab: string
|
||||
/** 第三栏双入口:工作(当前任务/收件箱)/ 文件(搜索、预览、编辑、下载)。 */
|
||||
rightTab: 'work' | 'files'
|
||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||
filesView: 'list' | 'preview'
|
||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||
inboxFilter: 'all' | 'todo'
|
||||
theme: ThemeChoice
|
||||
@@ -106,9 +109,10 @@ interface AppState {
|
||||
const DEFAULT_UI: UIState = {
|
||||
leftWidth: 260,
|
||||
leftCollapsed: false,
|
||||
rightWidth: 340,
|
||||
rightWidth: 360,
|
||||
rightCollapsed: false,
|
||||
rightTab: 'inbox',
|
||||
rightTab: 'work',
|
||||
filesView: 'list',
|
||||
inboxFilter: 'all',
|
||||
activeWorkspace: null,
|
||||
theme: 'dark',
|
||||
@@ -134,6 +138,9 @@ function loadUI(): UIState {
|
||||
if (!isThemeChoice(ui.theme)) ui.theme = 'dark'
|
||||
if (!isFontSizeChoice(ui.fontSize)) ui.fontSize = 14
|
||||
if (ui.inboxFilter !== 'all' && ui.inboxFilter !== 'todo') ui.inboxFilter = 'all'
|
||||
// 旧本地偏好里存的是 'inbox'——第三栏双入口上线时归一到 'work'。
|
||||
if (ui.rightTab !== 'work' && ui.rightTab !== 'files') ui.rightTab = 'work'
|
||||
if (ui.filesView !== 'list' && ui.filesView !== 'preview') ui.filesView = 'list'
|
||||
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
|
||||
ui.previewExpanded = false
|
||||
return ui
|
||||
@@ -160,19 +167,14 @@ function set(patch: Partial<AppState>) {
|
||||
listeners.forEach(l => l())
|
||||
}
|
||||
|
||||
// 预览区打开前右栏是否收起。null = 当前没有因为预览而改过右栏。
|
||||
let previewRightWasCollapsed: boolean | null = null
|
||||
|
||||
function syncPreviewRail(hasTabs: boolean) {
|
||||
// 第三栏「文件」入口联动(方案:预览并入第三栏):开第一张标签自动切到文件入口的
|
||||
// 预览子视图并展开右栏;关掉最后一张回到文件列表。不再收起右栏——预览与工作同居第三栏。
|
||||
function syncFilesEntry(hasTabs: boolean) {
|
||||
if (hasTabs) {
|
||||
if (previewRightWasCollapsed === null) previewRightWasCollapsed = state.ui.rightCollapsed
|
||||
if (!state.ui.rightCollapsed) setUI({ rightCollapsed: true })
|
||||
setUI({ rightTab: 'files', rightCollapsed: false, filesView: 'preview' })
|
||||
return
|
||||
}
|
||||
if (previewRightWasCollapsed === null) return
|
||||
const restore = previewRightWasCollapsed
|
||||
previewRightWasCollapsed = null
|
||||
setUI({ rightCollapsed: restore })
|
||||
if (state.ui.filesView === 'preview') setUI({ filesView: 'list' })
|
||||
}
|
||||
|
||||
function setUI(patch: Partial<UIState>) {
|
||||
@@ -367,7 +369,6 @@ export const store = {
|
||||
previewActive: { ...state.previewActive, [convId]: active },
|
||||
})
|
||||
}
|
||||
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -818,12 +819,14 @@ export const store = {
|
||||
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' })
|
||||
setUI({ rightCollapsed: false, rightTab: 'work', inboxFilter: 'todo' })
|
||||
},
|
||||
openInbox(cardId?: string) {
|
||||
setUI({ rightCollapsed: false, rightTab: 'inbox' })
|
||||
setUI({ rightCollapsed: false, rightTab: 'work' })
|
||||
if (cardId) setTimeout(() => flashLocate('.right-panel', 'data-card-id', cardId), 60)
|
||||
},
|
||||
/** 左栏/收起轨的「文件」入口:打开第三栏文件入口(列表或既有预览)。 */
|
||||
openFiles() { setUI({ rightTab: 'files', rightCollapsed: false }) },
|
||||
// 卡→消息互定位另一半(D32):从收件箱卡滚回产生它的对话消息。
|
||||
locateMessage(messageId?: string) {
|
||||
if (!messageId) return
|
||||
@@ -831,8 +834,8 @@ export const store = {
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
/**
|
||||
* 工件预览唯一入口:在对话右侧预览区打开或激活标签。
|
||||
* 打开第一张标签时收起右栏,关掉最后一张时恢复,并收回展开态。
|
||||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||
*/
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
@@ -841,7 +844,7 @@ export const store = {
|
||||
previewTabs: { ...state.previewTabs, [conv]: opened.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: opened.activeId },
|
||||
})
|
||||
syncPreviewRail(true)
|
||||
syncFilesEntry(true)
|
||||
},
|
||||
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
|
||||
activatePreview(id: string) {
|
||||
@@ -856,7 +859,7 @@ export const store = {
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
})
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncPreviewRail(next.tabs.length > 0)
|
||||
syncFilesEntry(next.tabs.length > 0)
|
||||
},
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||||
|
||||
+19
-10
@@ -24,11 +24,6 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
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;
|
||||
@@ -56,8 +51,12 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); }
|
||||
/* 作者 display 声明会压过 UA 的 [hidden],此处显式恢复;展开预览靠它隐藏对话列 */
|
||||
.main[hidden] { display: none !important; }
|
||||
/* 第三栏(工作|文件):文件入口承载预览,展开态铺满主区 */
|
||||
.right-panel { background: var(--bg-panel); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); display: flex; flex-direction: column; flex-shrink: 0; min-width: 0; }
|
||||
.right-panel.expanded { flex: 1 1 auto; width: auto; }
|
||||
.right-panel.collapsed { width: 44px !important; }
|
||||
/* 预览嵌进第三栏后去掉独立面板外壳(圆角/描边由右栏提供,纸面仍用 --bg) */
|
||||
.right-panel .preview-pane { flex: 1; min-height: 0; border: none; border-radius: 0; background: var(--bg); }
|
||||
|
||||
/* ---- 通用控件 ---- */
|
||||
.btn {
|
||||
@@ -196,9 +195,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tl-check { color: var(--text-dim); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||
.tl-tools .status-error { margin-left: 8px; color: var(--err-text); }
|
||||
|
||||
/* 对话右侧预览区 */
|
||||
.preview-slot { flex-shrink: 0; min-width: 0; display: flex; }
|
||||
.preview-slot.expanded { flex: 1 1 auto; width: auto; }
|
||||
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .right-panel .preview-pane) */
|
||||
.preview-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); overflow: hidden; }
|
||||
.preview-pane-bar { display: flex; align-items: center; gap: 8px; padding: 6px 10px; border-bottom: 1px solid var(--border-soft); }
|
||||
.preview-pane-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
@@ -256,6 +253,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
|
||||
/* 输入区:悬浮卡片式输入壳(16px 大圆角 = DESIGN.md 特批例外) */
|
||||
.input-bar { padding: 8px max(16px, 6%) 16px; background: var(--bg); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
/* 轻量状态提示行(快慢通道):一行以内,弱色;后台任务在回合结束后仍显示 */
|
||||
.input-status { display: flex; align-items: center; gap: 8px; padding: 0 4px 6px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
|
||||
.input-status-note { color: var(--text-faint); }
|
||||
.input-status-link { border: none; background: none; color: var(--accent-text); font-size: var(--fs-ui-sm); cursor: pointer; padding: 0; }
|
||||
.input-status-link:hover { text-decoration: underline; }
|
||||
.outbox-trigger { width: 100%; text-align: left; background: var(--bg-inset); color: var(--text-dim); border: 1px solid var(--border-soft); border-radius: var(--radius-xs); padding: 6px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.outbox-list { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.outbox-item { display: flex; gap: 6px; align-items: center; background: var(--bg-inset); border-radius: var(--radius-xs); padding: 6px 8px; }
|
||||
@@ -345,8 +347,13 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
/* ---- 品牌标(侧栏 / 登录页 / 头像共用的星芒渐变底;辅色为品牌令牌 --brand-2) ---- */
|
||||
.brand-mark { background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); display: inline-flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.22); }
|
||||
|
||||
/* ---- 左栏:任务中心(方案 A;Zai 复刻重造型) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 9px; padding: 14px 14px 8px; }
|
||||
/* ---- 左栏:项目导航(方案 A;三栏工作台重排:切换置顶、会话居中、文件沉底) ---- */
|
||||
.tsb-brand { display: flex; align-items: center; gap: 6px; padding: 12px 12px 8px; }
|
||||
.tsb-projrow { flex: 1; min-width: 0; display: flex; align-items: center; gap: 7px; height: 32px; padding: 0 9px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); cursor: pointer; font-size: var(--fs-ui-caption); font-weight: 600; }
|
||||
.tsb-projrow:hover { border-color: var(--accent-sidebar); }
|
||||
.tsb-projrow-name { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-projrow-stage { flex: none; font-size: var(--fs-ui-xs); font-weight: 450; color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-full); padding: 0 6px; }
|
||||
.tsb-projrow .tsb-user-chev { color: var(--text-faint); display: inline-flex; flex: none; }
|
||||
.tsb-logo { width: 24px; height: 24px; border-radius: var(--radius); background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 700; display: flex; align-items: center; justify-content: center; flex: none; }
|
||||
.tsb-name { font-weight: 650; font-size: var(--fs-ui-caption); letter-spacing: -0.01em; }
|
||||
.tsb-iconbtn { width: 26px; height: 26px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: var(--fs-ui-caption); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
@@ -404,6 +411,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tsb-row-act:hover { background: var(--bg-active); color: var(--text); }
|
||||
|
||||
.tsb-footer { display: flex; align-items: center; gap: 4px; padding: 10px 12px; border-top: 1px solid var(--border-soft); border-radius: 0 0 var(--radius-lg) var(--radius-lg); }
|
||||
/* 底部沉底区(方案:文件、工作区在账户上方) */
|
||||
.tsb-bottom { display: flex; flex-direction: column; gap: 1px; padding: 6px 10px 2px; border-top: 1px solid var(--border-soft); }
|
||||
.tsb-user { flex: 1; display: flex; align-items: center; gap: 9px; min-width: 0; height: 40px; padding: 0 7px; border: none; border-radius: var(--radius); background: none; cursor: pointer; text-align: left; transition: background .12s; }
|
||||
.tsb-user:hover { background: var(--bg-hover); }
|
||||
.tsb-avatar { width: 27px; height: 27px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--brand-2)); color: var(--text-on-accent); font-size: var(--fs-ui-sm); font-weight: 600; display: flex; align-items: center; justify-content: center; flex: none; box-shadow: inset 0 1px 0 rgba(255,255,255,.2); }
|
||||
|
||||
+2
-3
@@ -1,5 +1,6 @@
|
||||
// 体系工具排位:服务端声明的工作区对所有用户可见,按 shortcut 数字占位;
|
||||
// 内置「工件」「代理」补进空位。插件管理面板不进这条清单。
|
||||
// 内置「代理」补进空位。文件浏览在第三栏「文件」入口(方案:入口收敛),不再占体系工具位。
|
||||
// 插件管理面板不进这条清单。
|
||||
|
||||
export interface WorkspaceEntry {
|
||||
key: string
|
||||
@@ -16,7 +17,6 @@ export interface WorkspaceSource {
|
||||
}
|
||||
|
||||
const BUILTINS: Omit<WorkspaceEntry, 'shortcut'>[] = [
|
||||
{ key: 'artifacts', title: '工件', icon: '📦' },
|
||||
{ key: 'agents', title: '代理', icon: '🤖' },
|
||||
]
|
||||
|
||||
@@ -56,7 +56,6 @@ export function buildWorkspaceEntries(server: WorkspaceSource[]): WorkspaceEntry
|
||||
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)
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildWorkspaceEntries } from '../src/workspaces'
|
||||
|
||||
// 三栏工作台(2026-10-11):文件浏览并入第三栏「文件」入口,体系工具只补「代理」。
|
||||
describe('buildWorkspaceEntries', () => {
|
||||
it('没有领域插件时,工件和代理补在 ⌘1 ⌘2', () => {
|
||||
it('没有领域插件时,代理补在 ⌘1', () => {
|
||||
const entries = buildWorkspaceEntries([])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 工件', '⌘2 代理'])
|
||||
expect(entries.map(e => `${e.shortcut} ${e.title}`)).toEqual(['⌘1 代理'])
|
||||
})
|
||||
|
||||
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.map(e => e.title)).toEqual(['追溯网', '代理'])
|
||||
expect(entries.map(e => e.shortcut)).toEqual(['⌘1', '⌘2'])
|
||||
expect(entries[0].key).toBe('ws:trace')
|
||||
})
|
||||
|
||||
@@ -20,6 +21,6 @@ describe('buildWorkspaceEntries', () => {
|
||||
const entries = buildWorkspaceEntries([
|
||||
{ id: 'mem', title: '记忆库' },
|
||||
])
|
||||
expect(entries.map(e => e.title)).toEqual(['工件', '代理', '记忆库'])
|
||||
expect(entries.map(e => e.title)).toEqual(['代理', '记忆库'])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user