feat(plugins): 悬浮状态胶囊(B3 落地,复刻 ZCode 药丸⇄面板二态)

This commit is contained in:
Rick Ye
2026-09-11 15:49:50 +08:00
parent 1326f9b672
commit ad16ed7239
8 changed files with 315 additions and 200 deletions
+2 -2
View File
@@ -22,7 +22,7 @@ import {
} from './plugins/renderer-host'
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules'
import { PluginFloatingCapsule } from './plugins/PluginFloatingCapsule'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
import { applyThemeColor, watchColorScheme } from './lib/theme'
@@ -424,7 +424,7 @@ export default function App(): JSX.Element {
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
panelOpen={sideOpen}
onTogglePanel={toggleSide}
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
extra={<PluginFloatingCapsule entries={pluginsUi.status} plugins={plugins} onOpenView={handleOpenView} />}
/>
<div className="flex min-h-0 flex-1">
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
@@ -0,0 +1,277 @@
/**
* 悬浮状态胶囊(B3 槽位,复刻 ZCode「最小化药丸 ⇄ 展开面板」二态):
* - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem)
* - 宿主为每条状态记录 updatedAt:最小化药丸展示「插件最新的消息」
* (tone 点 + 插件图标 + 文本),点击展开面板
* - 展开面板 = 每个插件一个专属区域:区域头(图标+名称)可收起/展开
* (记忆在 localStorage),区域内是该插件的状态条目与详情行
* (行 = 图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链)
* - 面板高度固定,内容溢出滚动;点击面板外部 / Esc 收回最小化
*/
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { LuChevronDown, LuMinimize2 } from 'react-icons/lu'
import type { PluginInfo, PluginStatusEntry } from '@shared/types'
import type { StatusDetail } from '@aso/plugin-sdk'
import { PluginIconValue } from './plugin-icons'
/** 胶囊前导色点:tone 的安静表达(底保持中性,不整块染色) */
export const TONE_DOT_CLASS: Record<string, string> = {
default: 'bg-ink-3',
accent: 'bg-accent',
success: 'bg-green',
warn: 'bg-orange',
danger: 'bg-red'
}
/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */
const TRAILING_TONE_CLASS: Record<string, string> = {
default: 'text-ink-3',
accent: 'text-accent',
success: 'text-green',
warn: 'text-orange',
danger: 'text-red'
}
/** 收起区域记忆(跨启动) */
const SECTION_STORAGE_KEY = 'aso.floatcapsule.collapsed.v1'
function loadCollapsedSections(): string[] {
try {
const raw = JSON.parse(localStorage.getItem(SECTION_STORAGE_KEY) ?? '[]')
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === 'string') : []
} catch {
return []
}
}
/** 详情分组渲染(标题层由容器决定:面板里分区头是插件名,这里只渲染 sections/rows) */
function StatusDetailBody({
detail,
onOpenView
}: {
detail: StatusDetail
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
return (
<>
{detail.sections.map((section, si) => (
<div key={si} className={si > 0 ? 'mt-0.5' : ''}>
{section.title && (
<div className="px-2 pb-0.5 pt-1.5 text-[11px] font-medium text-ink-2">{section.title}</div>
)}
{section.rows.map((row, ri) => {
const clickable = Boolean(row.openView)
const content = (
<>
{row.icon && <PluginIconValue icon={row.icon} size={14} className="shrink-0 text-ink-2" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-4 text-ink">{row.label}</span>
{row.detail && <span className="mt-0.5 block truncate text-[11px] leading-4 text-ink-3">{row.detail}</span>}
</span>
{row.trailing && (
<span className={`shrink-0 text-[11.5px] font-medium ${TRAILING_TONE_CLASS[row.tone ?? 'default']}`}>
{row.trailing}
</span>
)}
</>
)
return clickable ? (
<button
key={ri}
type="button"
onClick={() => {
if (row.openView) onOpenView(row.openView.view, row.openView.params)
}}
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
{content}
</button>
) : (
<div key={ri} className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
{content}
</div>
)
})}
</div>
))}
</>
)
}
export function PluginFloatingCapsule({
entries,
plugins,
onOpenView
}: {
entries: PluginStatusEntry[]
plugins: PluginInfo[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const [open, setOpen] = useState(false)
const [collapsedSections, setCollapsedSections] = useState<string[]>(loadCollapsedSections)
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedSections))
}, [collapsedSections])
// 面板开着时:点击外部 / Esc 收回最小化
useEffect(() => {
if (!open) return
const onDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [open])
// 全部条目消失(插件停用/重载)时收回最小化
if (entries.length === 0) return null
// updatedAt 最新在前(最新消息 = 排序头)
const sorted = [...entries].sort((a, b) => b.updatedAt - a.updatedAt)
const latest = sorted[0]
const latestPlugin = plugins.find((p) => p.manifest.id === latest.pluginId)
// 按插件分组,组序沿用最新在前
const byPlugin = new Map<string, PluginStatusEntry[]>()
for (const entry of sorted) {
const arr = byPlugin.get(entry.pluginId) ?? []
arr.push(entry)
byPlugin.set(entry.pluginId, arr)
}
const toggleSection = (pluginId: string): void => {
setCollapsedSections((prev) =>
prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId]
)
}
if (!open) {
return (
<div ref={rootRef} className="relative shrink-0">
<button
type="button"
title="插件状态(点击展开)"
onClick={() => setOpen(true)}
className="flex max-w-48 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 transition-colors hover:border-line-strong hover:text-ink"
>
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[latest.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default}`}
/>
{latestPlugin && <PluginIconValue icon={latestPlugin.manifest.icon} size={12} className="shrink-0" />}
<span className="truncate">{latest.item.text}</span>
{sorted.length > 1 && (
<span className="shrink-0 rounded-full bg-hover px-1.5 text-[10px] leading-4 text-ink-3">{sorted.length}</span>
)}
</button>
</div>
)
}
return (
<div ref={rootRef} className="relative shrink-0">
<div className="absolute right-0 top-full z-50 mt-1.5 flex w-80 flex-col overflow-hidden rounded-card border border-line bg-surface text-left shadow-overlay [-webkit-app-region:no-drag]">
{/* 面板头:总量 + 收回最小化 */}
<div className="flex shrink-0 items-center justify-between gap-2 border-b border-line py-1.5 pl-3 pr-1">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">
插件状态
<span className="ml-1.5 text-[11px] font-normal text-ink-3">{byPlugin.size} 个插件</span>
</div>
<button
type="button"
aria-label="收起到悬浮胶囊"
title="收起"
onClick={() => setOpen(false)}
className="shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuMinimize2 size={13} />
</button>
</div>
{/* 固定高度内容区:插件太多/内容过长向下滚动 */}
<div className="h-[min(480px,58vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5">
{[...byPlugin.entries()].map(([pluginId, pluginEntries]) => {
const plugin = plugins.find((p) => p.manifest.id === pluginId)
const collapsed = collapsedSections.includes(pluginId)
return (
<div key={pluginId} className="mb-0.5 last:mb-0">
{/* 区域头:插件专属,可收起/展开 */}
<button
type="button"
aria-expanded={!collapsed}
onClick={() => toggleSection(pluginId)}
className="flex w-full items-center gap-1.5 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
{plugin ? (
<PluginIconValue icon={plugin.manifest.icon} size={14} className="shrink-0 text-ink-2" />
) : null}
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-ink-2">
{plugin?.manifest.name ?? pluginId}
</span>
<span className="shrink-0 text-[10.5px] text-ink-3">{pluginEntries.length}</span>
<LuChevronDown
size={12}
aria-hidden
className={`shrink-0 text-ink-3 transition-transform duration-150 ${collapsed ? '-rotate-90' : ''}`}
/>
</button>
{!collapsed && (
<div className="pb-1 pl-1">
{pluginEntries.map((entry) => {
const clickable = Boolean(entry.item.openView)
const body = (
<>
<span
aria-hidden
className={`size-1.5 shrink-0 rounded-full ${
TONE_DOT_CLASS[entry.item.tone ?? 'default'] ?? TONE_DOT_CLASS.default
}`}
/>
{entry.item.icon ? (
<PluginIconValue icon={entry.item.icon} size={13} className="shrink-0 text-ink-2" />
) : null}
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">{entry.item.text}</span>
</>
)
return (
<div key={entry.itemId}>
{clickable ? (
<button
type="button"
onClick={() => {
if (entry.item.openView) onOpenView(entry.item.openView.view, entry.item.openView.params)
}}
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
{body}
</button>
) : (
<div className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">{body}</div>
)}
{entry.item.detail && (
<div className="pb-1">
<StatusDetailBody detail={entry.item.detail} onOpenView={onOpenView} />
</div>
)}
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
</div>
</div>
)
}
@@ -1,185 +0,0 @@
/**
* 插件状态胶囊(TitleBar B2 槽位):数据驱动渲染主进程广播的状态条目。
* 顶栏胶囊只是紧凑状态条;点击行为分三档:
* 1. 声明了 detail → 展开宿主渲染的详情面板(ZCode 式:头部标题+关闭、
* 分组列表、行尾彩色数值;行内数据再负责深链)
* 2. 声明了 openView → 执行深链
* 3. 都没有 → 纯展示
* 聊天视图与插件视图下均可见。面板点击外部/Esc 关闭。
*/
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { LuX } from 'react-icons/lu'
import type { PluginStatusEntry } from '@shared/types'
import { PluginIconValue } from './plugin-icons'
/** 胶囊前导色点:tone 的安静表达(胶囊底保持中性,不整块染色) */
export const TONE_DOT_CLASS: Record<string, string> = {
default: 'bg-ink-3',
accent: 'bg-accent',
success: 'bg-green',
warn: 'bg-orange',
danger: 'bg-red'
}
/** 行尾数值的着色(ZCode 式:+76 绿 / -40 红 的纯色文本,不用底色块) */
const TRAILING_TONE_CLASS: Record<string, string> = {
default: 'text-ink-3',
accent: 'text-accent',
success: 'text-green',
warn: 'text-orange',
danger: 'text-red'
}
/**
* 详情面板(ZCode Git 工具式):头部标题 + 关闭,分组列表;
* 行 = 图标 + 标签(+次级说明)+ 行尾彩色数值。direction 控制从胶囊下方/上方弹出。
*/
export function StatusPopover({
entry,
onOpenView,
onClose,
direction = 'down'
}: {
entry: PluginStatusEntry
onOpenView: (view: string, params?: unknown) => void
onClose: () => void
direction?: 'down' | 'up'
}): ReactNode {
const { item } = entry
return (
<div
className={`absolute right-0 z-50 w-80 overflow-hidden rounded-card border border-line bg-surface shadow-overlay ${
direction === 'up' ? 'bottom-full mb-1.5' : 'top-full mt-1.5'
}`}
>
<div className="flex items-center justify-between gap-2 py-2 pl-3 pr-1.5">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{item.detail?.title ?? item.text}</div>
<button
type="button"
aria-label="关闭"
onClick={onClose}
className="shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuX size={13} />
</button>
</div>
<div className="max-h-96 overflow-y-auto overscroll-contain border-t border-line px-1.5 py-1.5">
{item.detail?.sections.map((section, si) => (
<div key={si} className={si > 0 ? 'mt-1' : ''}>
{section.title && (
<div className="px-2 pb-1 pt-1.5 text-[11px] font-medium text-ink-2">{section.title}</div>
)}
{section.rows.map((row, ri) => {
const clickable = Boolean(row.openView)
const content = (
<>
{row.icon && <PluginIconValue icon={row.icon} size={14} className="shrink-0 text-ink-2" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-[12px] leading-4 text-ink">{row.label}</span>
{row.detail && <span className="mt-0.5 block truncate text-[11px] leading-4 text-ink-3">{row.detail}</span>}
</span>
{row.trailing && (
<span className={`shrink-0 text-[11.5px] font-medium ${TRAILING_TONE_CLASS[row.tone ?? 'default']}`}>
{row.trailing}
</span>
)}
</>
)
return clickable ? (
<button
key={ri}
type="button"
onClick={() => {
if (row.openView) onOpenView(row.openView.view, row.openView.params)
}}
className="flex w-full items-center gap-2 rounded-control px-2 py-2 text-left transition-colors hover:bg-hover"
>
{content}
</button>
) : (
<div key={ri} className="flex w-full items-center gap-2 rounded-control px-2 py-2">
{content}
</div>
)
})}
</div>
))}
</div>
</div>
)
}
export function PluginStatusCapsules({
entries,
onOpenView
}: {
entries: PluginStatusEntry[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const [openKey, setOpenKey] = useState<string | null>(null)
const rootRef = useRef<HTMLDivElement | null>(null)
// 弹层开着时:点外部 / Esc 关闭;条目消失(插件停用/重载)也收起
useEffect(() => {
if (!openKey) return
const onDown = (e: MouseEvent): void => {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) setOpenKey(null)
}
const onKey = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpenKey(null)
}
document.addEventListener('mousedown', onDown)
document.addEventListener('keydown', onKey)
return () => {
document.removeEventListener('mousedown', onDown)
document.removeEventListener('keydown', onKey)
}
}, [openKey])
// 条目集合变化(停用/重载/删除)时收起弹层;按 key 序列比较,避免引用变化误关
const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',')
useEffect(() => {
setOpenKey(null)
}, [entryKeys])
if (entries.length === 0) return null
return (
<div ref={rootRef} className="flex min-w-0 items-center gap-1.5 [-webkit-app-region:no-drag]">
{entries.slice(0, 4).map(({ pluginId, itemId, item }) => {
const tone = item.tone ?? 'default'
const key = `${pluginId}:${itemId}`
const expandable = Boolean(item.detail)
const clickable = expandable || Boolean(item.openView)
const open = openKey === key
return (
<div key={key} className="relative shrink-0">
<button
type="button"
title={item.text}
onClick={() => {
if (item.detail) setOpenKey(open ? null : key)
else if (item.openView) onOpenView(item.openView.view, item.openView.params)
}}
className={`flex max-w-44 shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2 py-0.5 text-[11px] leading-[18px] text-ink-2 transition-colors ${
clickable ? 'cursor-pointer hover:border-line-strong hover:text-ink' : 'cursor-default'
} ${open ? 'border-line-strong text-ink' : ''}`}
>
<span aria-hidden className={`size-1.5 shrink-0 rounded-full ${TONE_DOT_CLASS[tone] ?? TONE_DOT_CLASS.default}`} />
{item.icon ? <PluginIconValue icon={item.icon} size={12} className="shrink-0" /> : null}
<span className="truncate">{item.text}</span>
</button>
{open && item.detail && (
<StatusPopover entry={{ pluginId, itemId, item }} onOpenView={onOpenView} onClose={() => setOpenKey(null)} />
)}
</div>
)
})}
{entries.length > 4 && (
<span className="shrink-0 rounded-full border border-line px-2 py-0.5 text-[11px] leading-[18px] text-ink-3">
+{entries.length - 4}
</span>
)}
</div>
)
}