Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69b1487fb7 | ||
|
|
32e327a831 | ||
|
|
3ed10e1220 | ||
|
|
204ca67dc7 | ||
|
|
339a9ebc2d | ||
|
|
475fd3a8d6 | ||
|
|
84b73ab328 | ||
|
|
c71a77bb22 | ||
|
|
50c7244601 | ||
|
|
2464c16e6b | ||
|
|
0a0b55612c | ||
|
|
62aa5f8a56 | ||
|
|
d384a3d778 | ||
|
|
787a09ecd3 | ||
|
|
345adf8e9b | ||
|
|
c401bfe1fe | ||
|
|
817c43e764 | ||
|
|
9badbb1f85 | ||
|
|
a775e1998f | ||
|
|
cf6f24e583 | ||
|
|
2f599c84f8 |
@@ -2,6 +2,7 @@
|
||||
node_modules
|
||||
dist/
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
|
||||
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
|
||||
.mimosa
|
||||
|
||||
+31
-33
@@ -8,17 +8,17 @@ 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'
|
||||
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
|
||||
const RIGHT_MIN = 280, RIGHT_MAX = 560
|
||||
const PREVIEW_MIN = 420, PREVIEW_MAX = 1100
|
||||
@@ -31,13 +31,16 @@ export function App() {
|
||||
}, [])
|
||||
|
||||
const ui = useStore(s => s.ui)
|
||||
const previewOpen = useStore(s => {
|
||||
const id = s.currentConvId
|
||||
return id ? (s.previewTabs[id]?.length ?? 0) > 0 : false
|
||||
})
|
||||
// 预览键与 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 viewingArtifact = useStore(s => s.ui.viewingArtifact)
|
||||
// 文件入口 + 预览标签在场时,第三栏用「阅读档」宽度(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')
|
||||
@@ -65,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 = () => {
|
||||
@@ -143,32 +149,25 @@ export function App() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="main">
|
||||
<div className="main" hidden={previewExpanded}>
|
||||
<Conversation />
|
||||
</div>
|
||||
|
||||
{previewOpen && (
|
||||
<>
|
||||
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
|
||||
<div className="preview-slot" style={{ 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>
|
||||
)}
|
||||
@@ -180,7 +179,6 @@ export function App() {
|
||||
{ui.projectSettingsId && <ProjectSettingsModal />}
|
||||
{ui.adminZoneOpen && <AdminZone />}
|
||||
{ui.changePwOpen && <ChangePasswordModal onClose={() => store.setUI({ changePwOpen: false })} />}
|
||||
{viewingArtifact && <DocViewer artifact={viewingArtifact} />}
|
||||
{toast && <div className="toast">{toast}</div>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -78,7 +78,10 @@ export function AgentTracker() {
|
||||
|
||||
function UsageBadge({ usage }: { usage?: Usage | null }) {
|
||||
if (!usage) return null
|
||||
return <span className="effect-badge" title="token 用量与时长">
|
||||
{usage.totalTokens > 0 ? `${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
|
||||
// N3 计费拆分:有上一请求数据时显示「+最近 · 累计(含缓存命中)」。
|
||||
const last = (usage.lastPromptTokens ?? 0) + (usage.lastCompletionTokens ?? 0)
|
||||
const cache = usage.cacheReadTokens ?? 0
|
||||
return <span className="effect-badge" title={cache > 0 ? `token 用量与时长(缓存命中 ${cache})` : 'token 用量与时长'}>
|
||||
{last > 0 ? `+${last} · ` : ''}{usage.totalTokens > 0 ? `累计 ${usage.totalTokens} tok · ` : ''}{(usage.durationMs / 1000).toFixed(1)}s{usage.toolCalls > 0 ? ` · ${usage.toolCalls} 工具` : ''}
|
||||
</span>
|
||||
}
|
||||
|
||||
@@ -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, 'full') }}><Icon name="eye" 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'
|
||||
@@ -124,7 +124,11 @@ function DeliverableCard({ artifact, updates = 0 }: { artifact: { artifactId: st
|
||||
const name = artifact.name || art?.name || '文件'
|
||||
const icon = artifactIcon(name, artifact.kind ?? art?.kind)
|
||||
const size = humanSize(art?.size)
|
||||
const open = () => { if (art) store.requestPreview(art) }
|
||||
const open = () => {
|
||||
if (art) { store.requestPreview(art); return }
|
||||
// 工件已不在当前项目(被删/跨项目残留):给一句话反馈,不再无声无息。
|
||||
store.toast(`「${name}」已不在当前项目中,无法预览`)
|
||||
}
|
||||
return (
|
||||
<div className="deliverable-card mk-rise-in" data-artifact-chip={artifact.artifactId}>
|
||||
<button type="button" className="dc-open" title={`预览 ${name}`} onClick={open}>
|
||||
@@ -480,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
|
||||
@@ -549,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}
|
||||
@@ -611,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,68 +0,0 @@
|
||||
// 文档查看器(4.4 → 预览能力收敛):格式渲染统一交给 @ui FilePreview
|
||||
// (md/图片/pdf/代码/数据/docx/xlsx/pptx/URL 一套分派),这里只保留 OverlayHost
|
||||
// 容器、工具栏与文本族的在线编辑保存。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store } from '../store'
|
||||
import { OverlayHost, Icon, Btn, FilePreview } from '@ui'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
|
||||
export function DocViewer({ artifact }: { artifact: Artifact }) {
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
const textKind = isTextKind(artifact)
|
||||
useEffect(() => {
|
||||
if (!textKind) return
|
||||
setContent(null)
|
||||
fetch(api.artifactContentUrl(artifact.id)).then(r => r.text()).then(setContent).catch(() => setContent('(读取失败)'))
|
||||
}, [artifact.id])
|
||||
|
||||
const source = {
|
||||
kind: 'artifact' as const,
|
||||
id: artifact.id,
|
||||
name: artifact.name,
|
||||
mime: artifact.mime,
|
||||
contentUrl: api.artifactContentUrl(artifact.id),
|
||||
downloadUrl: api.artifactDownloadUrl(artifact.id),
|
||||
}
|
||||
|
||||
return (
|
||||
<OverlayHost title={artifact.name} icon="📄" onClose={() => store.viewArtifact(null)}>
|
||||
<div className="docv">
|
||||
<div className="docv-toolbar">
|
||||
<span className="docv-meta">{artifact.kind}{humanSize(artifact.size) ? ` · ${humanSize(artifact.size)}` : ''}</span>
|
||||
<span className="spacer" />
|
||||
{dirty && <span className="docv-dirty">未保存</span>}
|
||||
{textKind && (
|
||||
<>
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(artifact.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<a className="btn v-ghost docv-dl" href={api.artifactDownloadUrl(artifact.id)} download={artifact.name}>
|
||||
<Icon name="download" size={14} /> 下载
|
||||
</a>
|
||||
</div>
|
||||
<div className="docv-body">
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input docv-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : (
|
||||
<FilePreview source={source} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayHost>
|
||||
)
|
||||
}
|
||||
|
||||
function isTextKind(a: Artifact): boolean {
|
||||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||||
}
|
||||
@@ -1,16 +1,40 @@
|
||||
// 对话右侧预览区:多标签、可关、全屏与下载。宽度由 App 的分隔条控制。
|
||||
import { FilePreview, TabStrip, Icon } from '@ui'
|
||||
// 第三栏「文件」入口的多标签预览:打开/切换/关闭标签、编辑保存、展开铺满与下载。
|
||||
// 列表 ⇄ 预览在同一入口内切换;宽度档(阅读档 420–1100)由 App 的分隔条控制。
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Btn, FilePreview, Icon, TabStrip } from '@ui'
|
||||
import { api } from '../api'
|
||||
import type { Artifact } from '../contracts'
|
||||
import { humanSize } from '../messageparts'
|
||||
import { previewTabsKey } from '../previewtabs'
|
||||
import { store, useStore } from '../store'
|
||||
|
||||
export function PreviewPane() {
|
||||
const convId = useStore(s => s.currentConvId)
|
||||
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
|
||||
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? null)
|
||||
// 键口径与 requestPreview 一致(previewTabsKey):空态预览也渲染。
|
||||
const tabs = useStore(s => s.previewTabs[previewTabsKey(s.currentConvId)] ?? [])
|
||||
const activeId = useStore(s => s.previewActive[previewTabsKey(s.currentConvId)] ?? null)
|
||||
const artifacts = useStore(s => s.artifacts)
|
||||
const expanded = useStore(s => s.ui.previewExpanded)
|
||||
const active = tabs.find(t => t.id === activeId) ?? tabs[0]
|
||||
const art = active ? artifacts.find(a => a.id === active.id) : undefined
|
||||
const textKind = !!art && isTextKind(art)
|
||||
const [content, setContent] = useState<string | null>(null)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [dirty, setDirty] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false)
|
||||
setDirty(false)
|
||||
setContent(null)
|
||||
if (!art || !isTextKind(art)) return
|
||||
let cancelled = false
|
||||
fetch(api.artifactContentUrl(art.id))
|
||||
.then(r => r.text())
|
||||
.then(text => { if (!cancelled) setContent(text) })
|
||||
.catch(() => { if (!cancelled) setContent('(读取失败)') })
|
||||
return () => { cancelled = true }
|
||||
}, [art?.id])
|
||||
|
||||
if (!active) return null
|
||||
const art = artifacts.find(a => a.id === active.id)
|
||||
return (
|
||||
<div className="preview-pane">
|
||||
<TabStrip
|
||||
@@ -20,12 +44,29 @@ 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>
|
||||
)}
|
||||
<span className="preview-pane-spacer" />
|
||||
{art && (
|
||||
<>
|
||||
<button type="button" className="icon-btn" title="全屏预览" onClick={() => store.requestPreview(art, 'full')}>
|
||||
<Icon name="eye" size={14} />
|
||||
{dirty && <span className="preview-pane-dirty">未保存</span>}
|
||||
{textKind && (
|
||||
<>
|
||||
<Btn variant="ghost" icon={editing ? 'eye' : 'list'} onClick={() => setEditing(e => !e)}>{editing ? '预览' : '编辑'}</Btn>
|
||||
{editing && <Btn variant="primary" icon="check" disabled={!dirty} onClick={async () => {
|
||||
await api.saveArtifactContent(art.id, content ?? '')
|
||||
setDirty(false)
|
||||
store.toast('已保存')
|
||||
}}>保存</Btn>}
|
||||
</>
|
||||
)}
|
||||
<button type="button" className="icon-btn" title={expanded ? '还原预览' : '展开预览'} aria-pressed={expanded} onClick={() => store.togglePreviewExpanded()}>
|
||||
<Icon name={expanded ? 'minimize' : 'maximize'} size={14} />
|
||||
</button>
|
||||
<a className="icon-btn" title="下载" href={api.artifactDownloadUrl(art.id)} download={art.name}>
|
||||
<Icon name="download" size={14} />
|
||||
@@ -34,7 +75,10 @@ export function PreviewPane() {
|
||||
)}
|
||||
</div>
|
||||
<div className="preview-pane-body">
|
||||
{art ? (
|
||||
{editing && textKind ? (
|
||||
<textarea className="search-input preview-pane-editor" value={content ?? ''}
|
||||
onChange={e => { setContent(e.target.value); setDirty(true) }} />
|
||||
) : art ? (
|
||||
<FilePreview source={{
|
||||
kind: 'artifact',
|
||||
id: art.id,
|
||||
@@ -48,3 +92,7 @@ export function PreviewPane() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function isTextKind(a: Artifact): boolean {
|
||||
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 插件工作区迁「系统管理」(AdminZone,管理员专属全屏视图)。
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api } from '../api'
|
||||
import { store, useStore } from '../store'
|
||||
import { isFontSizeChoice, store, useStore } from '../store'
|
||||
import type { ThemeChoice } from '../theme'
|
||||
import type { SettingDesc } from '../contracts'
|
||||
import { SettingsShell, SettingRow, SettingsGroup, Btn } from '@ui'
|
||||
@@ -25,6 +25,7 @@ export function SettingsModal() {
|
||||
const [draft, setDraft] = useState<Record<string, any>>({})
|
||||
const [saved, setSaved] = useState(false)
|
||||
const theme = useStore(s => s.ui.theme)
|
||||
const fontSize = useStore(s => s.ui.fontSize)
|
||||
|
||||
useEffect(() => {
|
||||
api.getSettings().then(s => {
|
||||
@@ -80,6 +81,17 @@ export function SettingsModal() {
|
||||
<option value="system">跟随系统</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
<SettingRow label="界面字号" desc="小、标准、大、特大。只保存在这台设备上,图标和间距不跟随缩放" effect="hot">
|
||||
<select value={fontSize} onChange={e => {
|
||||
const next = Number(e.target.value)
|
||||
if (isFontSizeChoice(next)) store.setUI({ fontSize: next })
|
||||
}}>
|
||||
<option value={13}>小</option>
|
||||
<option value={14}>标准</option>
|
||||
<option value={16}>大</option>
|
||||
<option value={18}>特大</option>
|
||||
</select>
|
||||
</SettingRow>
|
||||
</SettingsGroup>
|
||||
)}
|
||||
{section === 'settings' && Object.entries(groups).map(([g, descs]) => {
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -60,6 +60,12 @@ export interface Usage {
|
||||
promptTokens: number; completionTokens: number; totalTokens: number;
|
||||
durationMs: number; toolCalls: number;
|
||||
model?: string;
|
||||
// N3 计费拆分(additive;旧记录缺省为 0):
|
||||
// Last* 是最近一次请求的用量,Cache* 是缓存命中累计。
|
||||
lastPromptTokens?: number;
|
||||
lastCompletionTokens?: number;
|
||||
cacheReadTokens?: number;
|
||||
cacheWriteTokens?: number;
|
||||
}
|
||||
|
||||
export interface Run {
|
||||
@@ -179,6 +185,9 @@ export type UINodeType =
|
||||
|
||||
export interface UINode {
|
||||
type: UINodeType;
|
||||
/** Surface 层节点级标注(core ui.go U2):整页唯一稳定 id 与事实来源标识,旧路径不填。 */
|
||||
id?: string;
|
||||
source?: string[];
|
||||
title?: string;
|
||||
items?: string[];
|
||||
kv?: [string, string][];
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ export function workedLabel(createdAt?: string, endedAt?: string): string {
|
||||
return text ? `已工作 ${text}` : '已工作'
|
||||
}
|
||||
|
||||
/** 文件大小人类可读(工件面板/DocViewer 共用)。 */
|
||||
/** 文件大小人类可读(工件面板/预览栏共用)。 */
|
||||
export function humanSize(n: number | undefined): string {
|
||||
if (!n || n <= 0) return ''
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// docx 分页纸面按预览栏宽度等比缩小。只缩不放:栏宽够放下整页时保持 100%。
|
||||
// gutter 与 styles.css 里 .docx-wrapper 的左右 padding(12px + 12px)对齐。
|
||||
|
||||
export function computeFitScale(containerWidth: number, pageWidth: number, gutter: number): number {
|
||||
if (!Number.isFinite(containerWidth) || !Number.isFinite(pageWidth) || pageWidth <= 0) return 1
|
||||
if (!Number.isFinite(gutter)) return 1
|
||||
const available = containerWidth - gutter
|
||||
if (!(available > 0)) return 1
|
||||
return Math.min(1, available / pageWidth)
|
||||
}
|
||||
|
||||
const PAGE_GUTTER_PX = 24
|
||||
|
||||
/** host 是 docx-preview 的渲染根(内含 .docx-wrapper);el 是用来量栏宽的元素。
|
||||
* 拖动分隔条时 el 变宽,ResizeObserver 重算。返回的函数卸下监听并清掉行内缩放。 */
|
||||
export function fitPagesToWidth(host: HTMLElement, el: HTMLElement): () => void {
|
||||
const apply = () => {
|
||||
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||
const page = host.querySelector<HTMLElement>('section.docx')
|
||||
if (!wrapper || !page) return
|
||||
const scale = computeFitScale(el.clientWidth, page.offsetWidth, PAGE_GUTTER_PX)
|
||||
wrapper.style.transformOrigin = 'top left'
|
||||
wrapper.style.transform = scale < 1 ? `scale(${scale})` : ''
|
||||
const nextHeight = `${Math.round(wrapper.offsetHeight * scale)}px`
|
||||
if (host.style.height !== nextHeight) host.style.height = nextHeight
|
||||
host.style.overflow = 'hidden'
|
||||
host.style.minHeight = '0'
|
||||
}
|
||||
apply()
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return () => clearFit(host)
|
||||
}
|
||||
const observer = new ResizeObserver(() => apply())
|
||||
observer.observe(el)
|
||||
// 页内图片后加载只改变页纸高度,不改变栏宽。el 变宽和 wrapper 变高都要重算宿主高度。
|
||||
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||
if (wrapper && wrapper !== el) observer.observe(wrapper)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
clearFit(host)
|
||||
}
|
||||
}
|
||||
|
||||
function clearFit(host: HTMLElement) {
|
||||
const wrapper = host.querySelector<HTMLElement>('.docx-wrapper')
|
||||
if (wrapper) {
|
||||
wrapper.style.transform = ''
|
||||
wrapper.style.transformOrigin = ''
|
||||
}
|
||||
host.style.height = ''
|
||||
host.style.overflow = ''
|
||||
host.style.minHeight = ''
|
||||
}
|
||||
+2
-1
@@ -4,6 +4,7 @@
|
||||
import { configureFilePreviewLoaders, configureLinkPreviewFetcher, renderMarkdown, setSafeHTML, stripUnsafeLinks } from '@ui'
|
||||
import { auth } from './auth'
|
||||
import type { LinkPreviewData } from '@ui'
|
||||
import { fitPagesToWidth } from './previewfit'
|
||||
|
||||
const fetchBuf = async (url: string | undefined): Promise<ArrayBuffer> => {
|
||||
const r = await fetch(url!)
|
||||
@@ -43,7 +44,7 @@ const docxLoader = async (el: HTMLElement, source: { contentUrl?: string; name?:
|
||||
el.innerHTML = ''
|
||||
await renderAsync(buf, el, el, { className: 'docx', inWrapper: true, breakPages: true })
|
||||
stripUnsafeLinks(el)
|
||||
return
|
||||
return fitPagesToWidth(el, el)
|
||||
} catch (err) {
|
||||
console.error('[preview:docx] page render failed, falling back', source.name, err)
|
||||
}
|
||||
|
||||
@@ -25,3 +25,23 @@ export function closePreviewTab(
|
||||
const neighbor = next[Math.min(i, next.length - 1)] ?? null
|
||||
return { tabs: next, activeId: neighbor?.id ?? null }
|
||||
}
|
||||
|
||||
/** 预览标签的会话键。空态(未选会话)统一落在 '_':写入(requestPreview)与
|
||||
* 渲染(previewOpen/PreviewPane)共用同一口径,空态点开工件才能出现预览区。 */
|
||||
export function previewTabsKey(currentConvId: string | null | undefined): string {
|
||||
return currentConvId ?? '_'
|
||||
}
|
||||
|
||||
/** 进入会话时把空态打开的孤儿标签('_' 键)并入会话键下;按 id 去重,
|
||||
* 会话侧顺序在前,'_' 键清空。无孤儿或目标即 '_' 时原样返回。 */
|
||||
export function mergeOrphanPreviewTabs(
|
||||
tabsByConv: Record<string, PreviewTab[]>,
|
||||
convId: string,
|
||||
): Record<string, PreviewTab[]> {
|
||||
const orphan = tabsByConv['_'] ?? []
|
||||
if (orphan.length === 0 || convId === '_') return tabsByConv
|
||||
const own = tabsByConv[convId] ?? []
|
||||
const ids = new Set(own.map(t => t.id))
|
||||
const merged = [...own, ...orphan.filter(t => !ids.has(t.id))]
|
||||
return { ...tabsByConv, [convId]: merged, '_': [] }
|
||||
}
|
||||
|
||||
+85
-30
@@ -3,11 +3,18 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { api, connectEvents } from './api'
|
||||
import { applyTextDelta, applyThinkingDelta, applyToolCall, applyToolCallDelta, applyToolResult, applyToolOutput, applyArtifactCreated, applyArtifactUpdated, applyCheck, applyFinalizing, type LiveItem } from './live'
|
||||
import { closePreviewTab, openPreviewTab, type PreviewTab } from './previewtabs'
|
||||
import { closePreviewTab, mergeOrphanPreviewTabs, openPreviewTab, previewTabsKey, type PreviewTab } from './previewtabs'
|
||||
import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessage, promoteQueuedMessage, removeQueuedMessage, replaceQueuedText, type QueuedMessage } from './outbox'
|
||||
import { canFlushOutbox, deriveShellPhase } from './runstate'
|
||||
import { flashElement } from './scrollsync'
|
||||
import { isThemeChoice, resolveTheme, type ThemeChoice } from './theme'
|
||||
|
||||
type FontSizeChoice = 13 | 14 | 16 | 18
|
||||
const FONT_SIZE_CHOICES: readonly FontSizeChoice[] = [13, 14, 16, 18]
|
||||
|
||||
export function isFontSizeChoice(value: number): value is FontSizeChoice {
|
||||
return (FONT_SIZE_CHOICES as readonly number[]).includes(value)
|
||||
}
|
||||
import type {
|
||||
Project, Conversation, Message, Run, Artifact, HITLCard, PlanSnapshot, TodoList,
|
||||
AgentProfile, StreamEvent, PluginPanel, ActivityCard,
|
||||
@@ -36,16 +43,22 @@ export interface UIState {
|
||||
leftCollapsed: boolean
|
||||
rightWidth: number
|
||||
rightCollapsed: boolean
|
||||
rightTab: string
|
||||
/** 第三栏双入口:工作(当前任务/收件箱)/ 文件(搜索、预览、编辑、下载)。 */
|
||||
rightTab: 'work' | 'files'
|
||||
/** 文件入口子视图:列表 / 预览(预览标签存在时才有意义)。 */
|
||||
filesView: 'list' | 'preview'
|
||||
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
|
||||
inboxFilter: 'all' | 'todo'
|
||||
theme: ThemeChoice
|
||||
/** 界面字号,只写本机。对应 --ui-font-size。 */
|
||||
fontSize: FontSizeChoice
|
||||
autoCollapseProcess: boolean
|
||||
settingsOpen: boolean
|
||||
trackerRunId: string | null
|
||||
viewingArtifact: Artifact | null
|
||||
/** 预览区宽度。标签列表按会话放在 AppState,不进本地偏好。 */
|
||||
previewWidth: number
|
||||
/** 预览栏铺满主区。标签不持久化,这个标志在 loadUI 时强制复位。 */
|
||||
previewExpanded: boolean
|
||||
activeWorkspace: string | null
|
||||
taskOrg: 'project' | 'timeline'
|
||||
showArchived: boolean
|
||||
@@ -96,17 +109,19 @@ 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',
|
||||
fontSize: 14,
|
||||
autoCollapseProcess: true,
|
||||
settingsOpen: false,
|
||||
trackerRunId: null,
|
||||
viewingArtifact: null,
|
||||
previewWidth: 640,
|
||||
previewExpanded: false,
|
||||
taskOrg: 'project',
|
||||
showArchived: false,
|
||||
lastProjectId: null,
|
||||
@@ -121,7 +136,13 @@ function loadUI(): UIState {
|
||||
if (raw) {
|
||||
const ui = { ...DEFAULT_UI, ...JSON.parse(raw) } as 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
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
@@ -146,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>) {
|
||||
@@ -166,9 +182,21 @@ function setUI(patch: Partial<UIState>) {
|
||||
state = { ...state, ui }
|
||||
try { localStorage.setItem('as.ui', JSON.stringify(ui)) } catch { /* ignore */ }
|
||||
applyTheme(ui.theme)
|
||||
applyFontSize(ui.fontSize)
|
||||
listeners.forEach(l => l())
|
||||
}
|
||||
|
||||
// 会话恢复(空态点工件无反应的高频前置):currentConvId 不持久化时刷新必回空态,
|
||||
// 空态下 requestPreview 的标签挂在 '_' 键、渲染判空键——点击只剩弹层关闭,没有任何反馈。
|
||||
const CONV_KEY = 'as.conv'
|
||||
let convRestoreDone = false
|
||||
function persistConvId(id: string | null) {
|
||||
try { id ? localStorage.setItem(CONV_KEY, id) : localStorage.removeItem(CONV_KEY) } catch { /* ignore */ }
|
||||
}
|
||||
function savedConvId(): string | null {
|
||||
try { return localStorage.getItem(CONV_KEY) } catch { return null }
|
||||
}
|
||||
|
||||
let systemQuery: MediaQueryList | null = null
|
||||
let systemHandler: (() => void) | null = null
|
||||
|
||||
@@ -193,6 +221,12 @@ function applyTheme(theme: string) {
|
||||
}
|
||||
|
||||
applyTheme(state.ui.theme)
|
||||
applyFontSize(state.ui.fontSize)
|
||||
|
||||
function applyFontSize(size: FontSizeChoice) {
|
||||
if (typeof document === 'undefined') return
|
||||
document.documentElement.style.setProperty('--ui-font-size', `${size}px`)
|
||||
}
|
||||
|
||||
function flashLocate(root: string, attr: string, id: string) {
|
||||
if (typeof document === 'undefined') return
|
||||
@@ -231,7 +265,18 @@ export const store = {
|
||||
|
||||
// 跨项目任务列表(方案 A 侧栏数据源):归档视图切换时带 includeArchived 重取。
|
||||
async refreshTasks(includeArchived = state.ui.showArchived) {
|
||||
try { set({ tasks: await api.listTasks(includeArchived) }) } catch { /* 列表竞态,静默 */ }
|
||||
try {
|
||||
const tasks = await api.listTasks(includeArchived)
|
||||
set({ tasks })
|
||||
// 首次加载恢复上次会话(仅一次;此后用户主动切换/新建不再打扰)。
|
||||
if (!convRestoreDone) {
|
||||
convRestoreDone = true
|
||||
const saved = savedConvId()
|
||||
if (saved && !state.currentConvId && tasks.some(t => t.id === saved && !t.archived)) {
|
||||
store.openConversation(saved)
|
||||
}
|
||||
}
|
||||
} catch { /* 列表竞态,静默 */ }
|
||||
},
|
||||
|
||||
async refreshMe() {
|
||||
@@ -315,7 +360,15 @@ export const store = {
|
||||
async openConversation(convId: string) {
|
||||
if (sse) { sse.close(); sse = null }
|
||||
set({ currentConvId: convId, messages: [], runs: [], activeHitl: [], lastSeq: 0, live: null, plan: null, todos: null, cards: [], inboxScope: 'conv', outbox: [], outboxDraining: false, posting: false })
|
||||
syncPreviewRail((state.previewTabs[convId] ?? []).length > 0)
|
||||
persistConvId(convId)
|
||||
// 空态时打开的预览标签('_' 键)随会话进入一起迁移,不留在不可见的孤儿键下。
|
||||
if ((state.previewTabs['_'] ?? []).length > 0) {
|
||||
const active = state.previewActive[convId] ?? state.previewActive['_'] ?? null
|
||||
set({
|
||||
previewTabs: mergeOrphanPreviewTabs(state.previewTabs, convId),
|
||||
previewActive: { ...state.previewActive, [convId]: active },
|
||||
})
|
||||
}
|
||||
const resumeSeq = await store.reconcile()
|
||||
store.connectLive(resumeSeq ?? state.lastSeq)
|
||||
},
|
||||
@@ -766,45 +819,47 @@ 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
|
||||
setTimeout(() => flashLocate('.chat', 'data-msg-id', messageId), 60)
|
||||
},
|
||||
openTracker(runId: string) { setUI({ trackerRunId: runId }) },
|
||||
viewArtifact(a: Artifact | null) { setUI({ viewingArtifact: a }) },
|
||||
/**
|
||||
* 工件预览唯一入口。默认在对话右侧预览区打开或激活标签;hint 'full' 落位全屏
|
||||
* DocViewer。打开第一张标签时收起右栏,关掉最后一张时恢复。
|
||||
* 工件预览唯一入口:在第三栏「文件」入口打开或激活标签(多文件多标签)。
|
||||
* 打开第一张自动切到预览子视图;关掉最后一张回到文件列表,并收回展开态。
|
||||
*/
|
||||
requestPreview(a: Artifact, hint?: 'full') {
|
||||
if (hint === 'full') { setUI({ viewingArtifact: a }); return }
|
||||
const conv = state.currentConvId ?? '_'
|
||||
requestPreview(a: Artifact) {
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
|
||||
set({
|
||||
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) {
|
||||
const conv = state.currentConvId ?? '_'
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
set({ previewActive: { ...state.previewActive, [conv]: id } })
|
||||
},
|
||||
closePreview(id: string) {
|
||||
const conv = state.currentConvId ?? '_'
|
||||
const conv = previewTabsKey(state.currentConvId)
|
||||
const next = closePreviewTab(state.previewTabs[conv] ?? [], state.previewActive[conv] ?? null, id)
|
||||
set({
|
||||
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
|
||||
previewActive: { ...state.previewActive, [conv]: next.activeId },
|
||||
})
|
||||
syncPreviewRail(next.tabs.length > 0)
|
||||
if (next.tabs.length === 0) setUI({ previewExpanded: false })
|
||||
syncFilesEntry(next.tabs.length > 0)
|
||||
},
|
||||
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
|
||||
openProjectSettings(id: string | null) { setUI({ projectSettingsId: id }) },
|
||||
|
||||
+97
-85
@@ -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;
|
||||
@@ -54,8 +49,14 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.divider:hover::before, .divider.dragging::before { background: var(--border-strong); }
|
||||
.divider::after { content: ''; position: absolute; inset: 0 -3px; } /* 拖拽热区加宽 */
|
||||
.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 {
|
||||
@@ -70,7 +71,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.btn:disabled { opacity: .42; cursor: not-allowed; box-shadow: none; }
|
||||
.btn.ghost { background: var(--bg-raised); border: 1px solid var(--border-strong); color: var(--text-dim); box-shadow: none; }
|
||||
.btn.ghost:hover:not(:disabled) { color: var(--text); border-color: var(--text-faint); background: var(--bg-raised); }
|
||||
.btn.danger { background: var(--err); }
|
||||
.btn.danger { background: var(--err-text); color: var(--text-on-semantic); }
|
||||
.icon-btn {
|
||||
background: none; border: none; color: var(--text-faint);
|
||||
border-radius: var(--radius-sm); padding: 0; width: 28px; height: 28px;
|
||||
@@ -102,13 +103,13 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
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);
|
||||
background: var(--accent-soft); color: var(--accent-text); 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 { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-dim); 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; }
|
||||
.msg { margin-bottom: 20px; animation: mk-rise-in .18s var(--ease-out); }
|
||||
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-faint); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg-role { font-size: var(--fs-ui-sm); color: var(--text-dim); margin-bottom: 6px; display: flex; gap: 8px; align-items: center; }
|
||||
.msg-role .role-mark { width: 18px; height: 18px; border-radius: 5px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
.msg.notice { margin-bottom: 12px; }
|
||||
.notice-summary { font-size: var(--fs-ui-sm); color: var(--text-dim); background: none; border: none; padding: 0; cursor: pointer; }
|
||||
@@ -125,9 +126,9 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.part-thinking .mk-col-trigger[aria-expanded="true"] .part-thinking-chev { transform: rotate(90deg); }
|
||||
.part-thinking .part-thinking-body { white-space: pre-wrap; margin-top: 4px; }
|
||||
.part-tool { font-size: var(--fs-ui-sm); }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); }
|
||||
.tool-chip .status-ok { color: var(--ok); }
|
||||
.tool-chip .status-error { color: var(--err); }
|
||||
.tool-chip { display: inline-flex; gap: 6px; align-items: center; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 4px 9px; margin: 2px 4px 2px 0; font-family: var(--mono); font-size: var(--fs-code); }
|
||||
.tool-chip .status-ok { color: var(--ok-text); }
|
||||
.tool-chip .status-error { color: var(--err-text); }
|
||||
.part-text { line-height: 1.7; overflow-wrap: break-word; }
|
||||
/* 块级间距只由标签自身控制:禁用 pre-wrap(marked 输出的块间换行会被渲染成空行,
|
||||
叠加出"列表逐项空一行"的松散排版);行内换行语义交给 Markdown 段落。 */
|
||||
@@ -141,7 +142,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.part-text :is(ul,ol) { margin: 0.4em 0; padding-left: 1.6em; }
|
||||
.part-text li { margin: 0.15em 0; }
|
||||
.part-text li > p { margin: 0.2em 0; }
|
||||
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.part-text pre { background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 11px 13px; overflow-x: auto; font-family: var(--mono); font-size: var(--fs-code); }
|
||||
.part-text code { font-family: var(--mono); background: var(--bg-raised); border-radius: var(--radius-xs); padding: 1px 5px; font-size: var(--fs-ui-sm); }
|
||||
.part-text pre code { background: none; padding: 0; }
|
||||
.part-text blockquote { margin: 0.5em 0; padding: 0.1em 0 0.1em 10px; border-left: 3px solid var(--border-strong); color: var(--text-dim); }
|
||||
@@ -149,8 +150,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.part-text th, .part-text td { border: 1px solid var(--border); padding: 4px 10px; text-align: left; }
|
||||
.part-text th { background: var(--bg-raised); font-weight: 600; }
|
||||
.part-text hr { border: none; border-top: 1px solid var(--border); margin: 0.8em 0; }
|
||||
.part-text a { color: var(--accent); }
|
||||
.part-error { color: var(--err); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.part-text a { color: var(--accent-text); }
|
||||
.part-error { color: var(--err-text); background: var(--err-soft); border: 1px solid var(--err); border-radius: var(--radius-sm); padding: 8px 12px; }
|
||||
.part-error-detail { margin-top: 8px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.tool-preview, .tool-live-preview {
|
||||
margin: 0; white-space: pre-wrap; font-family: var(--mono); font-size: var(--fs-ui-sm);
|
||||
@@ -158,11 +159,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
}
|
||||
.tool-live-preview { margin-top: 6px; }
|
||||
.artifact-chip { display: inline-flex; align-items: stretch; background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; font-size: var(--fs-ui-caption); margin: 2px 0; }
|
||||
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
|
||||
.artifact-chip .ac-main { display: inline-flex; align-items: center; gap: 7px; color: var(--accent-text); background: none; border: none; padding: 5px 10px; cursor: pointer; font: inherit; max-width: 340px; }
|
||||
.artifact-chip .ac-main span, .artifact-chip .ac-main { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-chip:hover { border-color: var(--accent); }
|
||||
.artifact-chip .ac-act { display: inline-flex; align-items: center; padding: 5px 8px; color: var(--text-dim); border-left: 1px solid var(--border-soft); background: none; cursor: pointer; }
|
||||
.artifact-chip .ac-act:hover { color: var(--accent); background: var(--bg-hover); }
|
||||
.artifact-chip .ac-act:hover { color: var(--accent-text); background: var(--bg-hover); }
|
||||
|
||||
/* 交付物卡片:主区域与「预览」打开预览区,下载收在菜单里。 */
|
||||
.deliverable-card { position: relative; display: flex; align-items: center; gap: 8px; width: fit-content; max-width: 100%; padding: 8px 8px 8px 8px; margin: 6px 0 2px;
|
||||
@@ -173,8 +174,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
background: var(--bg-inset); color: var(--accent); border: 1px solid var(--border-soft); }
|
||||
.deliverable-card .dc-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.deliverable-card .dc-name { color: var(--text); font-weight: 600; font-size: var(--fs-ui-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 380px; }
|
||||
.deliverable-card .dc-meta { color: var(--text-faint); font-size: var(--fs-ui-sm); white-space: nowrap; }
|
||||
.deliverable-card .dc-act { flex: none; margin-left: 8px; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent);
|
||||
.deliverable-card .dc-meta { color: var(--text-dim); font-size: var(--fs-ui-sm); white-space: nowrap; }
|
||||
.deliverable-card .dc-act { flex: none; margin-left: 8px; padding: 5px 12px; border-radius: var(--radius-sm); color: var(--accent-text);
|
||||
font-size: var(--fs-ui-caption); border: 1px solid var(--border); background: var(--bg-panel); cursor: pointer; }
|
||||
.deliverable-card .dc-act:hover:not(:disabled) { border-color: var(--accent); }
|
||||
.deliverable-card .dc-act:disabled { opacity: .45; cursor: default; }
|
||||
@@ -187,35 +188,43 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
/* 工作时间线 */
|
||||
.tl-tools { margin: 4px 0; }
|
||||
.tl-tools-head { font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.tl-tools-list { margin: 2px 0 0; padding: 0 0 0 16px; color: var(--text-faint); font-size: var(--fs-ui-sm); }
|
||||
.tl-tools-list { margin: 2px 0 0; padding: 0 0 0 16px; color: var(--text-dim); font-size: var(--fs-ui-sm); }
|
||||
.tl-tools-list li { margin: 1px 0; }
|
||||
.tl-tools-name { color: var(--text-dim); }
|
||||
.tl-tools-sum { margin-left: 8px; color: var(--text-faint); }
|
||||
.tl-check { color: var(--text-faint); font-size: var(--fs-ui-sm); margin: 4px 0; }
|
||||
.tl-tools .status-error { margin-left: 8px; color: var(--err); }
|
||||
.tl-tools-sum { margin-left: 8px; color: var(--text-dim); }
|
||||
.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; }
|
||||
/* 第三栏「文件」入口内的多标签预览(外壳样式见 .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); }
|
||||
.preview-pane-spacer { flex: 1; }
|
||||
.preview-pane-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.preview-pane-body .ui-fp { height: 100%; }
|
||||
/* 只给下限,不钉死高度:内容超出时由本层滚动。PDF iframe 靠自身 flex:1 撑满。 */
|
||||
.preview-pane-body .ui-fp { min-height: 100%; }
|
||||
.preview-pane-body .ui-fp-pdf { height: 100%; min-height: 480px; border: none; }
|
||||
.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; }
|
||||
.docx-wrapper > section.docx { background: var(--bg) !important; color: var(--text); box-shadow: var(--shadow-md); }
|
||||
.preview-pane-meta { flex-shrink: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
|
||||
.preview-pane-dirty { color: var(--warn-text); font-size: var(--fs-ui-sm); }
|
||||
.preview-pane-editor {
|
||||
display: block; width: 100%; height: 100%; min-height: 0; resize: none;
|
||||
font-family: var(--mono); font-size: var(--fs-code); background: var(--bg); color: var(--text);
|
||||
}
|
||||
.docx-wrapper { background: var(--bg-inset) !important; padding: 12px; display: flex; flex-direction: column; align-items: flex-start !important; }
|
||||
/* 宽栏时纸张居中。auto margin 优先于 align-items;栏比纸窄时剩余空间为负,margin 归零,
|
||||
纸张仍贴左,previewfit 的 transform-origin: top left 不用改。 */
|
||||
.docx-wrapper > section.docx { background: var(--paper) !important; color: var(--paper-ink); box-shadow: var(--shadow-md); margin-inline: auto; }
|
||||
/* 过程段内联组(流式/关闭自动折叠时,按原位渲染的过程容器) */
|
||||
.part-group { margin: 2px 0; }
|
||||
|
||||
/* 过程折叠(run 终态后;Collapsible 契约:触发行 + mk-col 动画体) */
|
||||
.process-collapse { border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg-panel); margin: 6px 0; }
|
||||
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-faint); font-size: var(--fs-ui-sm); user-select: none; }
|
||||
.process-collapse .process-collapse-summary { padding: 7px 12px; color: var(--text-dim); font-size: var(--fs-ui-sm); user-select: none; }
|
||||
.process-collapse .process-collapse-summary:hover { color: var(--text-dim); }
|
||||
.process-collapse .process-collapse-chev { display: inline-flex; color: var(--text-faint); font-size: var(--fs-ui-xs); transition: transform .12s; }
|
||||
.process-collapse .mk-col-trigger[aria-expanded="true"] .process-collapse-chev { transform: rotate(90deg); }
|
||||
.process-collapse .process-collapse-meta { color: var(--text-faint); }
|
||||
.process-collapse .process-collapse-excerpt { color: var(--text-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||
.process-collapse .process-collapse-meta { color: var(--text-dim); }
|
||||
.process-collapse .process-collapse-excerpt { color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; }
|
||||
.process-collapse .body { padding: 4px 12px 10px; }
|
||||
|
||||
/* 流式状态行(呼吸/扫光走 mk-* 类;已工作计时弱色等宽) */
|
||||
@@ -228,7 +237,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.hitl-card { border: 1px solid var(--ok-border); background: var(--ok-soft); border-radius: var(--radius-lg); padding: 14px; margin: 10px 0; }
|
||||
.hitl-card.wait { border-color: var(--wait); background: var(--wait-soft); }
|
||||
.hitl-card.ask { border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.hitl-detail { white-space: pre-wrap; margin: 8px 0 0; font-size: var(--fs-ui-sm); color: var(--text-faint); font-family: inherit; }
|
||||
.hitl-detail { white-space: pre-wrap; margin: 8px 0 0; font-size: var(--fs-ui-sm); color: var(--text-dim); font-family: inherit; }
|
||||
.mode-select { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-ui-sm); color: var(--text-faint); }
|
||||
.mode-select select { font-size: var(--fs-ui-sm); color: var(--text); background: var(--bg-panel); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 4px 8px; }
|
||||
.hitl-q { font-weight: 600; margin-bottom: 10px; display: flex; gap: 8px; align-items: flex-start; }
|
||||
@@ -238,12 +247,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.hitl-choice:hover { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.hitl-choice .label { font-weight: 600; display: flex; gap: 8px; align-items: center; }
|
||||
.hitl-choice .desc { color: var(--text-dim); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||
.hitl-choice .impact { color: var(--warn); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
|
||||
.hitl-choice .impact { color: var(--warn-text); font-size: var(--fs-ui-sm); margin-top: 3px; }
|
||||
.hitl-choice .rec { font-size: var(--fs-ui-xs); color: var(--accent-text); border: 1px solid var(--accent); padding: 0 5px; border-radius: var(--radius-full); }
|
||||
.hitl-free { display: flex; gap: 8px; margin-top: 10px; }
|
||||
|
||||
/* 输入区:悬浮卡片式输入壳(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; }
|
||||
@@ -258,7 +272,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.cmd-menu { border: 1px solid var(--border); background: var(--bg-raised); border-radius: var(--radius); margin-bottom: 6px; max-height: 180px; overflow-y: auto; box-shadow: var(--shadow-md); }
|
||||
.cmd-item { padding: 7px 12px; cursor: pointer; display: flex; gap: 10px; font-size: var(--fs-ui-caption); }
|
||||
.cmd-item:hover, .cmd-item.active { background: var(--bg-hover); }
|
||||
.cmd-item .name { color: var(--accent); font-family: var(--mono); }
|
||||
.cmd-item .name { color: var(--accent-text); font-family: var(--mono); }
|
||||
|
||||
/* ---- 右栏 ---- */
|
||||
.tabs { display: flex; border-bottom: 1px solid var(--border-soft); overflow-x: auto; padding: 0 8px; }
|
||||
@@ -269,8 +283,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.step { display: flex; gap: 8px; align-items: flex-start; padding: 6px 4px; font-size: var(--fs-ui-caption); }
|
||||
.step-icon { width: 16px; text-align: center; flex-shrink: 0; }
|
||||
.step.pending .step-icon { color: var(--text-faint); }
|
||||
.step.in_progress .step-icon { color: var(--accent); }
|
||||
.step.done .step-icon { color: var(--ok); }
|
||||
.step.in_progress .step-icon { color: var(--accent-text); }
|
||||
.step.done .step-icon { color: var(--ok-text); }
|
||||
.step.skipped { color: var(--text-faint); text-decoration: line-through; }
|
||||
/* ---- 工件面板(工作区文件管理)---- */
|
||||
.artifacts-panel { display: flex; flex-direction: column; gap: 4px; }
|
||||
@@ -278,19 +292,19 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ap-toolbar .search-input { flex: 1; min-width: 120px; height: 30px; font-size: var(--fs-ui-sm); }
|
||||
.kind-pill { border: 1px solid var(--border); background: none; color: var(--text-dim); border-radius: var(--radius-full); padding: 3px 10px; font-size: var(--fs-ui-sm); cursor: pointer; }
|
||||
.kind-pill:hover { color: var(--text); background: var(--bg-hover); }
|
||||
.kind-pill.on { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.kind-pill.on { color: var(--accent-text); border-color: var(--accent); background: var(--accent-soft); }
|
||||
.ap-upload { cursor: pointer; }
|
||||
.artifact-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
||||
.artifact-row:hover { background: var(--bg-hover); }
|
||||
.artifact-row .ar-icon { display: flex; color: var(--text-dim); cursor: pointer; flex: none; }
|
||||
.artifact-row .ar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; cursor: pointer; }
|
||||
.artifact-row .name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
|
||||
.artifact-row .meta { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-row .meta { font-size: var(--fs-ui-sm); color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.artifact-row .ar-acts { display: none; align-items: center; gap: 2px; flex: none; }
|
||||
.artifact-row:hover .ar-acts { display: inline-flex; }
|
||||
.artifact-row .ar-acts .icon-btn { width: 26px; height: 26px; }
|
||||
.artifact-row .ar-acts .icon-btn.danger { color: var(--err); }
|
||||
.empty-note { color: var(--text-faint); font-size: var(--fs-ui-sm); padding: 14px 6px; }
|
||||
.artifact-row .ar-acts .icon-btn.danger { color: var(--err-text); }
|
||||
.empty-note { color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 14px 6px; }
|
||||
.agent-row { display: flex; align-items: center; gap: 8px; padding: 8px; border-radius: var(--radius-sm); cursor: pointer; font-size: var(--fs-ui-caption); }
|
||||
.agent-row:hover { background: var(--bg-hover); }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
@@ -315,20 +329,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ui-setting-control input:focus, .ui-setting-control select:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.ui-setting-control input[type="checkbox"] { width: auto; }
|
||||
.effect-badge { font-size: var(--fs-ui-xs); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border); color: var(--text-faint); white-space: nowrap; }
|
||||
.effect-hot { color: var(--ok); border-color: var(--ok); }
|
||||
.effect-new-run { color: var(--warn); border-color: var(--warn); }
|
||||
|
||||
/* ---- 文档查看器(单层 OverlayHost 内)---- */
|
||||
.docv { display: flex; flex-direction: column; min-height: 0; }
|
||||
.docv-toolbar { display: flex; align-items: center; gap: 8px; padding: 0 0 10px; border-bottom: 1px solid var(--border-soft); margin-bottom: 10px; }
|
||||
.docv-toolbar .spacer { flex: 1; }
|
||||
.docv-meta { font-size: var(--fs-ui-xs); color: var(--text-faint); text-transform: capitalize; }
|
||||
.docv-dirty { color: var(--warn); font-size: var(--fs-ui-sm); }
|
||||
.docv-dl { text-decoration: none; }
|
||||
.docv-body { flex: 1; min-height: 0; overflow: auto; }
|
||||
.docv-pdf { width: 100%; height: 100%; min-height: 60vh; border: 1px solid var(--border-soft); border-radius: var(--radius); background: var(--bg); }
|
||||
.docv-editor { width: 100%; height: 56vh; font-family: var(--mono); font-size: var(--fs-ui-sm); resize: vertical; }
|
||||
.docv-pre { font-family: var(--mono); font-size: var(--fs-ui-sm); white-space: pre-wrap; word-break: break-word; background: var(--bg-inset); border: 1px solid var(--border-soft); border-radius: var(--radius); padding: 12px 14px; }
|
||||
.effect-hot { color: var(--ok-text); border-color: var(--ok); }
|
||||
.effect-new-run { color: var(--warn-text); border-color: var(--warn); }
|
||||
|
||||
/* ---- 杂项 ---- */
|
||||
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border); color: var(--text); padding: 9px 16px; border-radius: var(--radius-full); box-shadow: var(--shadow-lg); z-index: 90; font-size: var(--fs-ui-caption); }
|
||||
@@ -345,24 +347,29 @@ 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; }
|
||||
.tsb-iconbtn:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-iconbtn.active { color: var(--accent); background: var(--accent-soft); }
|
||||
.tsb-iconbtn.active { color: var(--accent-sidebar); background: var(--accent-soft); }
|
||||
|
||||
.tsb-actions { padding: 6px 12px 8px; display: flex; flex-direction: column; gap: 7px; }
|
||||
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary); color: var(--primary-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
|
||||
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary) 86%, var(--bg)); }
|
||||
.tsb-newtask { display: flex; height: 36px; border-radius: var(--radius); background: var(--primary-sidebar); color: var(--primary-sidebar-foreground); overflow: hidden; box-shadow: 0 1px 3px rgba(0, 0, 0, .3); transition: background .12s; }
|
||||
.tsb-newtask:hover { background: color-mix(in srgb, var(--primary-sidebar) 86%, var(--bg-sidebar)); }
|
||||
.tsb-newtask-main { flex: 1; display: flex; align-items: center; gap: 7px; padding: 0 11px; background: none; border: none; color: inherit; font-weight: 600; font-size: var(--fs-ui-caption); cursor: pointer; min-width: 0; }
|
||||
.tsb-newtask-main svg { flex: none; }
|
||||
.tsb-newtask-proj { font-weight: 450; font-size: var(--fs-ui-xs); opacity: .75; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-foreground) 8%, transparent); }
|
||||
.tsb-newtask-caret { width: 30px; border: none; border-left: 1px solid color-mix(in srgb, var(--primary-sidebar-foreground) 18%, transparent); background: none; color: inherit; cursor: pointer; display: flex; align-items: center; justify-content: center; }
|
||||
.tsb-newtask-caret:hover { background: color-mix(in srgb, var(--primary-sidebar-foreground) 8%, transparent); }
|
||||
.tsb-search { display: flex; align-items: center; gap: 7px; height: 32px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-faint); padding: 0 10px; transition: border-color .12s, box-shadow .12s; }
|
||||
.tsb-search:focus-within { border-color: var(--accent); box-shadow: var(--ring); }
|
||||
.tsb-search:focus-within { border-color: var(--accent-sidebar); box-shadow: var(--ring); }
|
||||
.tsb-search svg { flex: none; }
|
||||
.tsb-search input { flex: 1; border: none; background: none; color: var(--text); font-size: var(--fs-ui-sm); outline: none; min-width: 0; }
|
||||
.tsb-search input::placeholder { color: var(--text-faint); }
|
||||
@@ -374,8 +381,8 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tsb-pill.on { background: var(--bg-raised); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.25); }
|
||||
|
||||
.tsb-scroll { flex: 1; overflow-y: auto; padding: 0 10px 8px; min-height: 0; }
|
||||
.tsb-seclabel { font-size: var(--fs-ui-xs); color: var(--text-faint); padding: 12px 8px 4px; letter-spacing: .05em; font-weight: 600; }
|
||||
.tsb-empty { padding: 20px 14px; color: var(--text-faint); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
|
||||
.tsb-seclabel { font-size: var(--fs-ui-sm); color: var(--text-dim); padding: 12px 8px 4px; font-weight: 600; }
|
||||
.tsb-empty { padding: 20px 14px; color: var(--text-dim); font-size: var(--fs-ui-sm); line-height: 1.9; text-align: center; }
|
||||
.tsb-newproject { padding: 0 2px 8px; }
|
||||
|
||||
.tsb-group { margin-bottom: 2px; }
|
||||
@@ -392,11 +399,11 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tsb-task { display: flex; align-items: center; gap: 8px; height: 31px; padding: 0 8px; margin: 1px 0; border-radius: var(--radius-sm); color: var(--text-dim); cursor: pointer; position: relative; transition: background .1s, color .1s; }
|
||||
.tsb-task:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-task.active { background: var(--bg-active); color: var(--text); }
|
||||
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent); }
|
||||
.tsb-task.active::before { content: ''; position: absolute; left: -2px; top: 6px; bottom: 6px; width: 2.5px; border-radius: 2px; background: var(--accent-sidebar); }
|
||||
.tsb-task.archived .tsb-task-t { text-decoration: line-through; opacity: .6; }
|
||||
.tsb-task-t { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-size: var(--fs-ui-caption); }
|
||||
.tsb-task-tm { font-size: var(--fs-ui-xs); color: var(--text-faint); font-variant-numeric: tabular-nums; }
|
||||
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-faint); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
|
||||
.tsb-pbadge { font-size: var(--fs-ui-xs); color: var(--text-dim); border: 1px solid var(--border); border-radius: var(--radius-xs); padding: 0 5px; line-height: 1.5; white-space: nowrap; }
|
||||
.tsb-row-acts { display: none; gap: 2px; }
|
||||
.tsb-task:hover .tsb-row-acts { display: inline-flex; }
|
||||
.tsb-task:hover .tsb-task-tm { display: none; }
|
||||
@@ -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); }
|
||||
@@ -412,14 +421,17 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.tsb-user-sub { font-size: var(--fs-ui-xs); color: var(--text-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.tsb-user-chev { color: var(--text-faint); display: inline-flex; }
|
||||
|
||||
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease); }
|
||||
.tsb-menu { position: fixed; z-index: 300; min-width: 216px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius); box-shadow: var(--shadow-lg); padding: 5px; display: flex; flex-direction: column; animation: mk-modal-in .12s var(--ease);
|
||||
/* 菜单挂在侧栏里,但纸面是主区底。侧栏对 --text* 的重映射不能漏进来。 */
|
||||
--text: var(--text-workspace); --text-dim: var(--text-workspace-dim); --text-faint: var(--text-workspace-faint);
|
||||
color: var(--text-workspace); }
|
||||
.tsb-mi { display: flex; align-items: center; gap: 9px; min-height: 31px; padding: 4px 9px; border: none; border-radius: var(--radius-sm); background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); cursor: pointer; text-align: left; }
|
||||
.tsb-mi svg { color: var(--text-faint); flex: none; }
|
||||
.tsb-mi:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.tsb-mi:hover svg { color: var(--text-dim); }
|
||||
.tsb-mi.danger { color: var(--err); }
|
||||
.tsb-mi.danger:hover { background: var(--err); color: var(--text-on-accent); }
|
||||
.tsb-mi.danger:hover svg { color: var(--text-on-accent); }
|
||||
.tsb-mi.danger { color: var(--err-text); }
|
||||
.tsb-mi.danger:hover { background: var(--err-text); color: var(--text-on-semantic); }
|
||||
.tsb-mi.danger:hover svg { color: var(--text-on-semantic); }
|
||||
.tsb-mi kbd { margin-left: auto; font-size: var(--fs-ui-xs); font-family: var(--mono); color: var(--text-faint); }
|
||||
.tsb-menu-div { height: 1px; background: var(--border-soft); margin: 5px 4px; }
|
||||
.tsb-acc-head { display: flex; gap: 10px; align-items: center; padding: 8px 9px 6px; }
|
||||
@@ -451,7 +463,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.lg-input { width: 100%; height: 38px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-raised); color: var(--text); padding: 0 12px; font-size: var(--fs-ui-caption); transition: border-color .12s, box-shadow .12s; }
|
||||
.lg-input:focus { border-color: var(--accent); box-shadow: var(--ring); outline: none; }
|
||||
.lg-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.lg-err { margin-top: 12px; font-size: var(--fs-ui-sm); color: var(--err-text); background: var(--err-soft); border: 1px solid color-mix(in srgb, var(--err) 35%, transparent); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.lg-err svg { flex: none; margin-top: 1px; }
|
||||
.lg-submit {
|
||||
width: 100%; height: 38px; margin-top: 16px; border: none; border-radius: var(--radius);
|
||||
@@ -478,7 +490,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.az-nav-item { display: flex; align-items: center; gap: 9px; padding: 9px 11px; border: none; background: none; color: var(--text-dim); font-size: var(--fs-ui-caption); border-radius: var(--radius-sm); cursor: pointer; text-align: left; }
|
||||
.az-nav-item:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.az-nav-item.on { background: var(--bg-active); color: var(--text); font-weight: 550; }
|
||||
.az-nav-item.on svg { color: var(--accent); }
|
||||
.az-nav-item.on svg { color: var(--accent-text); }
|
||||
.az-main { flex: 1; min-width: 0; overflow-y: auto; padding: 20px 24px; }
|
||||
.az-section-head { margin-bottom: 14px; }
|
||||
.az-section-head h3 { margin: 0 0 4px; font-size: var(--fs-ui-lg); font-weight: 600; letter-spacing: -0.01em; }
|
||||
@@ -493,18 +505,18 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.ad-create-btn { width: auto; padding: 0 14px; margin-top: 0; height: 34px; flex: none; }
|
||||
.ad-mini { border: 1px solid var(--border); background: var(--bg-raised); color: var(--text-dim); border-radius: var(--radius-sm); padding: 4px 9px; font-size: var(--fs-ui-sm); cursor: pointer; white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; }
|
||||
.ad-mini:hover { color: var(--text); border-color: var(--accent); }
|
||||
.ad-hint { margin: 0; font-size: var(--fs-ui-xs); color: var(--text-faint); }
|
||||
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.ad-hint { margin: 0; font-size: var(--fs-ui-sm); color: var(--text-dim); }
|
||||
.ad-notice { font-size: var(--fs-ui-sm); color: var(--ok-text); background: var(--ok-soft); border: 1px solid var(--ok-border); border-radius: var(--radius); padding: 8px 11px; display: flex; gap: 7px; align-items: flex-start; }
|
||||
.ad-notice svg { flex: none; margin-top: 1px; }
|
||||
.ad-table { width: 100%; border-collapse: collapse; font-size: var(--fs-ui-sm); }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-faint); font-size: var(--fs-ui-xs); text-transform: uppercase; letter-spacing: .06em; padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table th { text-align: left; font-weight: 600; color: var(--text-dim); font-size: var(--fs-ui-sm); padding: 8px; border-bottom: 1px solid var(--border); }
|
||||
.ad-table td { padding: 9px 8px; border-bottom: 1px solid var(--border-soft); }
|
||||
.ad-table tr:last-child td { border-bottom: none; }
|
||||
.ad-mono { font-family: var(--mono); font-size: var(--fs-ui-sm); }
|
||||
.ad-dim { color: var(--text-faint); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-faint); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.ad-dim { color: var(--text-dim); }
|
||||
.ad-chip { font-size: var(--fs-ui-xs); border-radius: var(--radius-xs); padding: 2px 7px; font-weight: 700; letter-spacing: .5px; background: var(--bg-hover); color: var(--text-dim); }
|
||||
.ad-chip-admin { background: var(--accent-soft); color: var(--accent-text); }
|
||||
.ad-chip-warn { background: var(--warn-soft); color: var(--warn-text); }
|
||||
.ad-reset { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border: 1px solid color-mix(in srgb, var(--warn) 40%, transparent); background: var(--warn-soft); border-radius: var(--radius-lg); padding: 12px; font-size: var(--fs-ui-sm); }
|
||||
.ad-reset .lg-input { flex: 1; min-width: 160px; height: 32px; }
|
||||
.ad-changepw { display: flex; flex-direction: column; gap: 2px; max-width: 340px; }
|
||||
@@ -513,7 +525,7 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.icon-only { display: flex; flex-direction: column; align-items: center; gap: 6px; padding-top: 12px; }
|
||||
.icon-only button { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 0; width: 34px; height: 34px; border-radius: var(--radius); display: inline-flex; align-items: center; justify-content: center; position: relative; }
|
||||
.icon-only button:hover { background: var(--bg-hover); color: var(--text); }
|
||||
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn); color: var(--text-on-accent); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
|
||||
.rail-badge { position: absolute; top: 3px; right: 3px; min-width: 14px; height: 14px; border-radius: 7px; background: var(--warn-text); color: var(--text-on-semantic); font-size: var(--fs-ui-xs); font-weight: 700; display: flex; align-items: center; justify-content: center; padding: 0 4px; }
|
||||
|
||||
/* ---- UI 能力平台消费(消息头像列 / 设置外壳 / 管理弹层头像) ---- */
|
||||
.role-mark { width: 20px; height: 20px; border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; color: var(--text-dim); background: var(--bg-raised); border: 1px solid var(--border); }
|
||||
@@ -540,7 +552,7 @@ button { font-family: inherit; font-size: inherit; 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-project-meta { color: var(--text-dim); 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;
|
||||
@@ -548,10 +560,10 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
}
|
||||
.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-note { color: var(--text-dim); 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);
|
||||
background: var(--err-text); color: var(--text-on-semantic); font-size: var(--fs-ui-xs);
|
||||
}
|
||||
.rewind-row { margin-top: 8px; }
|
||||
.rewind-panel {
|
||||
@@ -564,4 +576,4 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
|
||||
.msg-tools { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; margin-top: 4px; }
|
||||
.session-note { width: 12rem; }
|
||||
.rewind-actions { display: flex; gap: 8px; }
|
||||
.rewind-error { color: var(--err); font-size: var(--fs-ui-sm); }
|
||||
.rewind-error { color: var(--err-text); font-size: var(--fs-ui-sm); }
|
||||
|
||||
+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)
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// 空态点工件无反应(2026-10-10 实测复现):requestPreview 写 '_' 键、渲染判
|
||||
// currentConvId 非空——键口径不一致导致空态点击只剩弹层关闭。这里测纯函数口径。
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mergeOrphanPreviewTabs, previewTabsKey } from '../src/previewtabs'
|
||||
|
||||
describe('previewTabsKey', () => {
|
||||
it('falls back to the empty-state key when no conversation is selected', () => {
|
||||
expect(previewTabsKey(null)).toBe('_')
|
||||
expect(previewTabsKey(undefined)).toBe('_')
|
||||
expect(previewTabsKey('cnv_1')).toBe('cnv_1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mergeOrphanPreviewTabs', () => {
|
||||
it('merges empty-state tabs into the conversation key and clears the orphan key', () => {
|
||||
const got = mergeOrphanPreviewTabs({
|
||||
_: [{ id: 'a', name: 'A.docx' }, { id: 'b', name: 'B.md' }],
|
||||
cnv_1: [{ id: 'b', name: 'B.md' }],
|
||||
}, 'cnv_1')
|
||||
expect(got._).toEqual([])
|
||||
expect(got.cnv_1.map(t => t.id)).toEqual(['b', 'a'])
|
||||
})
|
||||
|
||||
it('returns the input untouched when there is no orphan or the target is the orphan key', () => {
|
||||
const none = { cnv_1: [{ id: 'a', name: 'A' }] }
|
||||
expect(mergeOrphanPreviewTabs(none, 'cnv_1')).toBe(none)
|
||||
const self = { _: [{ id: 'a', name: 'A' }] }
|
||||
expect(mergeOrphanPreviewTabs(self, '_')).toBe(self)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, describe, it, expect } from 'vitest'
|
||||
import { computeFitScale, fitPagesToWidth } from '../src/previewfit'
|
||||
|
||||
describe('computeFitScale', () => {
|
||||
it('keeps the page at 100% when the column is wider than the page', () => {
|
||||
expect(computeFitScale(1100, 794, 24)).toBe(1)
|
||||
expect(computeFitScale(818, 794, 24)).toBe(1)
|
||||
})
|
||||
|
||||
it('scales the page down to the column width after subtracting the gutter', () => {
|
||||
expect(computeFitScale(420, 794, 24)).toBeCloseTo((420 - 24) / 794)
|
||||
expect(computeFitScale(794, 794, 24)).toBeCloseTo((794 - 24) / 794)
|
||||
})
|
||||
|
||||
it('does not invert or zoom when the width is unusable', () => {
|
||||
expect(computeFitScale(0, 794, 24)).toBe(1)
|
||||
expect(computeFitScale(20, 794, 24)).toBe(1)
|
||||
expect(computeFitScale(420, 0, 24)).toBe(1)
|
||||
expect(computeFitScale(Number.NaN, 794, 24)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
// node 测试环境没有 DOM。这里只实现 fitPagesToWidth 会碰到的查询与尺寸。
|
||||
class FakeEl {
|
||||
style: { height: string; overflow: string; minHeight: string; transform: string; transformOrigin: string } = {
|
||||
height: '', overflow: '', minHeight: '', transform: '', transformOrigin: '',
|
||||
}
|
||||
clientWidth = 0
|
||||
offsetWidth = 0
|
||||
offsetHeight = 0
|
||||
className = ''
|
||||
tag = 'div'
|
||||
children: FakeEl[] = []
|
||||
querySelector(sel: string): FakeEl | null {
|
||||
const walk = (nodes: FakeEl[]): FakeEl | null => {
|
||||
for (const node of nodes) {
|
||||
if (matches(node, sel)) return node
|
||||
const hit = walk(node.children)
|
||||
if (hit) return hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
return walk(this.children)
|
||||
}
|
||||
}
|
||||
|
||||
function matches(el: FakeEl, sel: string): boolean {
|
||||
const classes = el.className.split(/\s+/).filter(Boolean)
|
||||
if (sel.startsWith('.')) return classes.includes(sel.slice(1))
|
||||
const [tag, cls] = sel.split('.')
|
||||
return el.tag === tag && classes.includes(cls)
|
||||
}
|
||||
|
||||
class FakeResizeObserver {
|
||||
static observed: FakeEl[] = []
|
||||
static instances: FakeResizeObserver[] = []
|
||||
constructor(private callback: () => void) {
|
||||
FakeResizeObserver.instances.push(this)
|
||||
}
|
||||
observe(el: FakeEl) { FakeResizeObserver.observed.push(el) }
|
||||
disconnect() { /* dispose 只要求卸下监听 */ }
|
||||
static fire() {
|
||||
for (const instance of FakeResizeObserver.instances) instance.callback()
|
||||
}
|
||||
static reset() {
|
||||
FakeResizeObserver.observed = []
|
||||
FakeResizeObserver.instances = []
|
||||
}
|
||||
}
|
||||
|
||||
describe('fitPagesToWidth', () => {
|
||||
const previous = globalThis.ResizeObserver
|
||||
|
||||
afterEach(() => {
|
||||
FakeResizeObserver.reset()
|
||||
globalThis.ResizeObserver = previous
|
||||
})
|
||||
|
||||
it('recomputes the host height when the page wrapper grows and clears inline styles on dispose', () => {
|
||||
globalThis.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
|
||||
const host = new FakeEl()
|
||||
host.clientWidth = 420
|
||||
const wrapper = new FakeEl()
|
||||
wrapper.className = 'docx-wrapper'
|
||||
wrapper.offsetHeight = 1000
|
||||
const page = new FakeEl()
|
||||
page.tag = 'section'
|
||||
page.className = 'docx'
|
||||
page.offsetWidth = 794
|
||||
wrapper.children.push(page)
|
||||
host.children.push(wrapper)
|
||||
|
||||
const dispose = fitPagesToWidth(host as unknown as HTMLElement, host as unknown as HTMLElement)
|
||||
const scale = (420 - 24) / 794
|
||||
expect(host.style.height).toBe(`${Math.round(1000 * scale)}px`)
|
||||
expect(host.style.overflow).toBe('hidden')
|
||||
expect(FakeResizeObserver.observed).toContain(wrapper)
|
||||
|
||||
wrapper.offsetHeight = 1500
|
||||
FakeResizeObserver.fire()
|
||||
expect(host.style.height).toBe(`${Math.round(1500 * scale)}px`)
|
||||
|
||||
dispose()
|
||||
expect(host.style.height).toBe('')
|
||||
expect(host.style.overflow).toBe('')
|
||||
expect(host.style.minHeight).toBe('')
|
||||
expect(wrapper.style.transform).toBe('')
|
||||
expect(wrapper.style.transformOrigin).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -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(['代理', '记忆库'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"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/runstate.test.ts","./src/runstate.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/BorrowPanels.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/html-sinks.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/send.test.ts","./test/tasklist.test.ts","./test/theme.test.ts","./test/toolkind.test.ts","./test/workspaces.test.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user