Compare commits

..
Author SHA1 Message Date
weichen 50c7244601 Hide the conversation column when the preview expands (#47)
Restore hidden on the conversation column and center wide docx pages.
2026-10-05 05:38:03 +00:00
weichenandCursor 2464c16e6b Hide the conversation column when the preview expands
Why: .main { display: flex } overrides the UA [hidden] rule, so previewExpanded left the conversation column in the row and squeezed the preview.
Evidence: vitest test/previewfit.test.ts and test/design-guard.test.ts passed (10 tests). Headless Chromium at 1512x900: expanded .main is display:none and the preview slot is 1184px with the page centered (center delta 0); at 420px and 640px the wrapper stays column-width and the page stays left-aligned.
Impact: expanded preview fills the main row; docx pages center only when the column is wider than the page. Narrow-column fit scaling is unchanged. previewfit still returns 1 when available width is <= 0.
Refs: App.tsx hidden={previewExpanded}; styles.css .main and .docx-wrapper
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 05:37:21 +00:00
weichen 0a0b55612c Make the preview pane the only scrolling preview 2026-10-05 04:42:24 +00:00
weichen 62aa5f8a56 Merge remote-tracking branch 'origin/main' into feature/preview-scroll-consolidate
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	src/styles.css
2026-10-05 04:42:19 +00:00
weichenandCursor d384a3d778 Make the preview pane the only scrolling preview
Why: The pane pinned .ui-fp to the column height, and the docx fit host then clipped overflow, so the right-hand preview could not scroll. The fullscreen DocViewer was a second shell around the same FilePreview.
Evidence: npx vitest run (17 files, 82 tests) and npx tsc --noEmit passed. Headless Chromium laid out a 1400px docx host inside a 764px pane (body scrollHeight 1400) and a pdf iframe that filled that pane.
Impact: .preview-pane-body is the scroll owner. Docx refit also watches page height. The overlay viewer is gone; expand/restore and text edit/save live on the pane.
Refs: Agent-System-Dev#196, Agent-System-Frontend#44
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 04:41:20 +00:00
weichen 787a09ecd3 Merge pull request 'Make content text readable and add a local font size' (#45) from feature/contrast-typography into main 2026-10-05 04:41:15 +00:00
weichen 345adf8e9b Merge remote-tracking branch 'origin/main' into feature/contrast-typography 2026-10-05 04:41:02 +00:00
weichenandCursor c401bfe1fe Make content text readable and add a local font size
Why: Headers, HITL details, tool lists, and status words used faint text, semantic colors were used as text, and the molv sidebar primary button matched the navy surface. There was no control for --ui-font-size.
Evidence: npx vitest run (17 files, 81 tests), npx tsc --noEmit, and knip unused exports 43 <= 43 passed in modules/frontend.
Impact: Content classes move to --text-dim or *-text. Sidebar active, focus, and the new-task button use the sidebar tokens. Settings gains 小/标准/大/特大, stored only in localStorage as ui.fontSize. Before: no font-size control and a 1:1 new-task button on molv. After: four local sizes and a light sidebar button.
Refs: feature/contrast-typography
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 04:37:04 +00:00
weichen 817c43e764 Merge pull request 'Scale docx pages to the preview column' (#44) from feature/docx-paper-preview into main
Scale docx pages to the preview column
2026-10-05 04:06:12 +00:00
weichenandCursor 9badbb1f85 Stop tracking the TypeScript incremental build info
Why: tsconfig.tsbuildinfo is a local tsc cache, and a fresh typecheck rewrites it, which leaves the frontend submodule dirty.
Evidence: git ls-files still listed it after the ignore rule, and git status showed it modified after npx tsc -b.
Impact: The file remains on disk and is now ignored. No source or preview behavior changes.
Refs: feature/docx-paper-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 03:59:45 +00:00
weichenandCursor a775e1998f Ignore TypeScript incremental build info
Why: tsc -b writes tsconfig.tsbuildinfo into the worktree, which showed up as a dirty submodule during the paper-preview check.
Evidence: git status listed tsconfig.tsbuildinfo as untracked after npx tsc -b.
Impact: The artifact stays local. No runtime or preview behavior changes.
Refs: feature/docx-paper-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 03:59:30 +00:00
weichenandCursor cf6f24e583 Scale docx pages to the preview column
Why: An A4 page is wider than the preview column, and the page background followed the app theme, so body text disappeared in dark mode.
Evidence: npm test (17 files, 81 tests) and npx tsc -b passed in modules/frontend.
Impact: Docx pages stay on --paper with their own styles and shrink to fit the column. A column wider than the page, including fullscreen, keeps them at 100%. Before: section background var(--bg) and color var(--text). After: var(--paper) and var(--paper-ink).
Refs: feature/docx-paper-preview
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 03:59:00 +00:00
weichen 2f599c84f8 Add secret, injection, and message controls to the workbench
Merge pull request 'Add secret, injection, and message controls to the workbench' (#43) from feature/sillytavern-review into main
2026-10-04 19:44:56 +00:00
13 changed files with 345 additions and 169 deletions
+1
View File
@@ -2,6 +2,7 @@
node_modules
dist/
*.local
*.tsbuildinfo
# mimosa 扫描钩子的本地缓存(scripts/hooks 写入,不入库)
.mimosa
+6 -6
View File
@@ -14,7 +14,6 @@ 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'
@@ -37,7 +36,7 @@ export function App() {
})
const toast = useStore(s => s.toast)
const trackerRunId = useStore(s => s.ui.trackerRunId)
const viewingArtifact = useStore(s => s.ui.viewingArtifact)
const previewExpanded = previewOpen && 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')
@@ -143,14 +142,16 @@ 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 }}>
{!previewExpanded && (
<div className="divider" onMouseDown={onDividerDown('preview')} title="拖动调整预览区宽度" />
)}
<div className={`preview-slot mk-panel-grow${previewExpanded ? ' expanded' : ''}`} style={previewExpanded ? undefined : { width: ui.previewWidth }}>
<PreviewPane />
</div>
</>
@@ -180,7 +181,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>
)
+2 -2
View File
@@ -37,7 +37,7 @@ export function ArtifactsPanel() {
: visible.length === 0
? <div className="empty-note">没有匹配「{q}」的文件</div>
: visible.map(a => (
// 预览落在对话右侧预览区。弹层盖住主区,先收起再打开。
// 预览落在对话右侧预览区。工作区弹层盖住主区,先收起再打开。
<div key={a.id} className="artifact-row" data-artifact-row={a.id}
onClick={() => { store.closeWorkspace(); store.requestPreview(a) }} title={`预览 ${a.name}`}>
<span className="ar-icon"><Icon name={artifactIcon(a.name, a.kind)} size={15} /></span>
@@ -46,7 +46,7 @@ export function ArtifactsPanel() {
<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.closeWorkspace(); 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>
-68
View File
@@ -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)
}
+50 -6
View File
@@ -1,6 +1,10 @@
// 对话右侧预览区:多标签、可关、全屏与下载。宽度由 App 的分隔条控制。
import { FilePreview, TabStrip, Icon } from '@ui'
// 对话右侧预览区:多标签、可关、展开与下载。宽度由 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 { store, useStore } from '../store'
export function PreviewPane() {
@@ -8,9 +12,28 @@ export function PreviewPane() {
const tabs = useStore(s => (convId ? s.previewTabs[convId] : undefined) ?? [])
const activeId = useStore(s => (convId ? s.previewActive[convId] : null) ?? 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
@@ -21,11 +44,25 @@ export function PreviewPane() {
/>
<div className="preview-pane-bar">
<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 +71,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 +88,7 @@ export function PreviewPane() {
</div>
)
}
function isTextKind(a: Artifact): boolean {
return a.kind === 'document' && /\.(md|txt|csv|log|json)$/i.test(a.name)
}
+13 -1
View File
@@ -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 -1
View File
@@ -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']
+53
View File
@@ -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
View File
@@ -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)
}
+28 -7
View File
@@ -8,6 +8,13 @@ import { deliveryRequestId, enqueueMessage, enqueueMessageFront, newQueuedMessag
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,
@@ -40,12 +47,15 @@ export interface UIState {
/** 收件箱筛选:全部 / 待处理。铃铛打开时切到待处理。 */
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
@@ -102,11 +112,12 @@ const DEFAULT_UI: UIState = {
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 +132,10 @@ 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'
// 预览标签不进本地偏好。展开态若随刷新残留,会在没有标签时藏起对话。
ui.previewExpanded = false
return ui
}
} catch { /* ignore */ }
@@ -166,6 +180,7 @@ 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())
}
@@ -193,6 +208,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
@@ -778,13 +799,11 @@ export const store = {
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 }
requestPreview(a: Artifact) {
const conv = state.currentConvId ?? '_'
const opened = openPreviewTab(state.previewTabs[conv] ?? [], { id: a.id, name: a.name })
set({
@@ -793,6 +812,7 @@ export const store = {
})
syncPreviewRail(true)
},
togglePreviewExpanded() { setUI({ previewExpanded: !state.ui.previewExpanded }) },
activatePreview(id: string) {
const conv = state.currentConvId ?? '_'
set({ previewActive: { ...state.previewActive, [conv]: id } })
@@ -804,6 +824,7 @@ export const store = {
previewTabs: { ...state.previewTabs, [conv]: next.tabs },
previewActive: { ...state.previewActive, [conv]: next.activeId },
})
if (next.tabs.length === 0) setUI({ previewExpanded: false })
syncPreviewRail(next.tabs.length > 0)
},
openSettings(open: boolean) { setUI({ settingsOpen: open }) },
+79 -76
View File
@@ -54,6 +54,8 @@ 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.collapsed { width: 44px !important; }
@@ -70,7 +72,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 +104,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 +127,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 +143,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 +151,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 +160,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 +175,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 +189,45 @@ 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; }
.preview-slot.expanded { flex: 1 1 auto; width: auto; }
.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 +240,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,8 +250,8 @@ 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 特批例外) */
@@ -258,7 +270,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 +281,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 +290,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 +327,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); }
@@ -351,18 +351,18 @@ button { font-family: inherit; font-size: inherit; color: inherit; }
.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 +374,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 +392,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; }
@@ -412,14 +412,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 +454,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 +481,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 +496,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 +516,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 +543,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 +551,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 +567,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); }
+110
View File
@@ -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
View File
@@ -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"}