14 changed files with 461 additions and 228 deletions
+22 -3
View File
@@ -132,6 +132,8 @@ class PluginHost {
/** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */ /** 状态胶囊/工具栏按钮:pluginId → (itemId → data) */
private statusItems = new Map<string, Map<string, StatusItem>>() private statusItems = new Map<string, Map<string, StatusItem>>()
private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>() private toolbarButtons = new Map<string, Map<string, ToolbarButtonDef>>()
/** 状态条目最近写入时间(悬浮胶囊"最新消息"判定),key = pluginId\0itemId */
private statusUpdatedAt = new Map<string, number>()
/** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */ /** 跨插件服务注册表(pluginId → serviceName → impl);每次 runtime 重建时清空重建 */
private services = new Map<string, Map<string, unknown>>() private services = new Map<string, Map<string, unknown>>()
/** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */ /** 已声明的存储 schema(api.storage.declareSchema;管理页 Tooltip 与 Agent 内省数据源) */
@@ -339,6 +341,7 @@ class PluginHost {
log.info('reload begin') log.info('reload begin')
this.statusItems.clear() this.statusItems.clear()
this.toolbarButtons.clear() this.toolbarButtons.clear()
this.statusUpdatedAt.clear()
this.services.clear() this.services.clear()
this.storageSchemas.clear() this.storageSchemas.clear()
await this.scan() await this.scan()
@@ -534,7 +537,14 @@ class PluginHost {
uiState(): PluginsUiState { uiState(): PluginsUiState {
const status: PluginStatusEntry[] = [] const status: PluginStatusEntry[] = []
for (const [pluginId, items] of this.statusItems) { for (const [pluginId, items] of this.statusItems) {
for (const [itemId, item] of items) status.push({ pluginId, itemId, item }) for (const [itemId, item] of items) {
status.push({
pluginId,
itemId,
item,
updatedAt: this.statusUpdatedAt.get(`${pluginId}\0${itemId}`) ?? Date.now()
})
}
} }
const toolbar: PluginToolbarEntry[] = [] const toolbar: PluginToolbarEntry[] = []
for (const [pluginId, buttons] of this.toolbarButtons) { for (const [pluginId, buttons] of this.toolbarButtons) {
@@ -552,8 +562,14 @@ class PluginHost {
items = new Map() items = new Map()
this.statusItems.set(pluginId, items) this.statusItems.set(pluginId, items)
} }
if (item) items.set(itemId, item) const stampKey = `${pluginId}\0${itemId}`
else items.delete(itemId) if (item) {
items.set(itemId, item)
this.statusUpdatedAt.set(stampKey, Date.now())
} else {
items.delete(itemId)
this.statusUpdatedAt.delete(stampKey)
}
if (items.size === 0) this.statusItems.delete(pluginId) if (items.size === 0) this.statusItems.delete(pluginId)
this.send('plugins:uiState', this.uiState()) this.send('plugins:uiState', this.uiState())
} }
@@ -581,6 +597,9 @@ class PluginHost {
private clearPluginUi(pluginId: string): void { private clearPluginUi(pluginId: string): void {
this.statusItems.delete(pluginId) this.statusItems.delete(pluginId)
this.toolbarButtons.delete(pluginId) this.toolbarButtons.delete(pluginId)
for (const key of [...this.statusUpdatedAt.keys()]) {
if (key.startsWith(`${pluginId}\0`)) this.statusUpdatedAt.delete(key)
}
this.settingsSchemas.delete(pluginId) this.settingsSchemas.delete(pluginId)
this.dropSettingsCallbacks(pluginId) this.dropSettingsCallbacks(pluginId)
this.services.delete(pluginId) this.services.delete(pluginId)
+31 -2
View File
@@ -22,7 +22,7 @@ import {
} from './plugins/renderer-host' } from './plugins/renderer-host'
import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui' import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui'
import { PluginErrorBoundary } from './plugins/PluginErrorBoundary' import { PluginErrorBoundary } from './plugins/PluginErrorBoundary'
import { PluginStatusCapsules } from './plugins/PluginStatusCapsules' import { PluginFloatingCapsule } from './plugins/PluginFloatingCapsule'
import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store' import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store'
import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components' import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components'
import { applyThemeColor, watchColorScheme } from './lib/theme' import { applyThemeColor, watchColorScheme } from './lib/theme'
@@ -329,6 +329,32 @@ export default function App(): JSX.Element {
// 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失) // 浏览器插件(内置):面板 tab 按其启用态门控(禁用后 tab 与可开列表同步消失)
const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true const browserEnabled = plugins.find((p) => p.manifest.id === 'browser')?.enabled ?? true
// Agent 调用浏览器工具 → 自动展开侧面板并激活浏览器标签。
// 每回合(agent_start)只强制一次:回合中用户手动收起面板不被反复顶开。
const browserAutoOpenedRef = useRef(false)
useEffect(() => {
return window.api.onEvent((event) => {
if (event.type === 'agent_start') {
browserAutoOpenedRef.current = false
return
}
if (
event.type === 'tool_start' &&
event.toolName.startsWith('browser_') &&
!browserAutoOpenedRef.current &&
browserEnabled
) {
browserAutoOpenedRef.current = true
setSide((s) => ({
...s,
open: true,
tabs: s.tabs.includes('browser') ? s.tabs : [...s.tabs, 'browser'],
active: 'browser'
}))
}
})
}, [browserEnabled])
// 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页) // 面板标签:browser(宿主内置组件)+ manifest 声明 panel 的外置插件(iframe 页)
const panelTabs = sideTabIds.flatMap((id) => { const panelTabs = sideTabIds.flatMap((id) => {
if (id === 'browser') { if (id === 'browser') {
@@ -424,8 +450,11 @@ export default function App(): JSX.Element {
title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name} title={activeView.view === 'chat' ? sessionTitle : activePlugin?.manifest.name}
panelOpen={sideOpen} panelOpen={sideOpen}
onTogglePanel={toggleSide} onTogglePanel={toggleSide}
extra={<PluginStatusCapsules entries={pluginsUi.status} onOpenView={handleOpenView} />}
/> />
{/* 悬浮状态胶囊:main 区右上角(侧面板打开时左移让位) */}
<div className="absolute top-12 z-40" style={{ right: sideOpen ? sideWidth + 12 : 12 }}>
<PluginFloatingCapsule entries={pluginsUi.status} plugins={plugins} onOpenView={handleOpenView} />
</div>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
{/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */} {/* 聊天列:切到插件视图时 display:none 保活(草稿/流式/滚动全保留) */}
<div <div
@@ -2,8 +2,8 @@ import { memo, useEffect, useMemo, useRef, useState, useTransition, type ReactNo
import type { JSX, RefObject } from 'react' import type { JSX, RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual' import { useVirtualizer } from '@tanstack/react-virtual'
import { import {
LuArrowLeft,
LuCheck, LuCheck,
LuChevronRight,
LuCpu, LuCpu,
LuDatabase, LuDatabase,
LuFolderOpen, LuFolderOpen,
@@ -1542,20 +1542,46 @@ export function SettingsModal({
{/* 右侧:内容页 */} {/* 右侧:内容页 */}
<div className="flex min-w-0 flex-1 flex-col"> <div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-start gap-3 px-6 pb-3 pt-5"> <div className="flex items-start gap-3 px-6 pb-3 pt-5">
{tab.startsWith('plugin:') && ( {tab.startsWith('plugin:') ? (
<button // 二级页顶部:面包屑(导航层,shadcn 语义——上级可点、ChevronRight 分隔、当前页仅标记)
type="button" // + 页标题(与一级页同级 16px semibold)+ 元数据行(来源 · 版本)
onClick={() => setTab('plugins')} (() => {
className="mt-0.5 flex shrink-0 items-center gap-1 rounded-control px-1.5 py-1 text-[11.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink" const info = plugins.find((p) => `plugin:${p.manifest.id}` === tab)
> const originLabel =
<LuArrowLeft size={13} /> !info || info.source === 'builtin' ? '内置' : info.origin === 'project' ? '项目' : '全局'
返回 return (
</button> <div className="min-w-0 flex-1">
<nav aria-label="面包屑" className="flex items-center gap-1 text-[11.5px]">
<button
type="button"
onClick={() => setTab('plugins')}
className="rounded-control px-1 py-0.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink"
>
插件
</button>
<LuChevronRight size={12} aria-hidden="true" className="shrink-0 text-ink-3" />
<span aria-current="page" className="truncate text-ink-2">
{info?.manifest.name ?? '插件'}
</span>
</nav>
<div className="mt-1.5 flex min-w-0 items-center gap-2">
{info && <PluginIconValue icon={info.manifest.icon} size={16} className="shrink-0 text-ink-2" />}
<div className="truncate text-[16px] font-semibold text-ink">{info?.manifest.name ?? '插件'}</div>
</div>
{info && (
<div className="mt-0.5 text-[12px] leading-5 text-ink-3">
{originLabel}插件 · v{info.manifest.version}
</div>
)}
</div>
)
})()
) : (
<div className="min-w-0 flex-1">
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
</div>
)} )}
<div className="min-w-0 flex-1">
<div className="text-[16px] font-semibold text-ink">{meta.title}</div>
{meta.desc && <div className="mt-0.5 text-[12px] leading-5 text-ink-3">{meta.desc}</div>}
</div>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -70,18 +70,26 @@ export function SidePanel({
onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta))) onWidthChange(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragRef.current.startWidth + delta)))
} }
const onUp = (): void => { const onUp = (): void => {
if (!dragRef.current) return
dragRef.current = null dragRef.current = null
document.body.style.userSelect = ''
} }
window.addEventListener('pointermove', onMove) window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp) window.addEventListener('pointerup', onUp)
return () => { return () => {
window.removeEventListener('pointermove', onMove) window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp) window.removeEventListener('pointerup', onUp)
document.body.style.userSelect = ''
} }
}, [onWidthChange]) }, [onWidthChange])
const startResize = (e: React.PointerEvent): void => { const startResize = (e: React.PointerEvent): void => {
// preventDefault 必须在 pointerdown 上:否则 Chromium 会把它当作文本选择
// 手势的锚点,拖动经过聊天区/面板文字时把沿途内容选中;拖拽期间再对
// body 全局禁选(双保险,覆盖手势未被拦截的边缘情况),松手恢复
e.preventDefault()
dragRef.current = { startX: e.clientX, startWidth: width } dragRef.current = { startX: e.clientX, startWidth: width }
document.body.style.userSelect = 'none'
} }
// 菜单打开时 Esc 关闭 // 菜单打开时 Esc 关闭
@@ -455,8 +455,9 @@ export function SidebarNav({
})} })}
</GlideMenu> </GlideMenu>
{/* 会话列表区 */} {/* 会话列表区:折叠态 margin 收敛为 1px,让「会话」切换图标与上方
<div className="mt-3 min-h-0 flex-1 overflow-y-auto"> 导航图标保持同一垂直节奏(行高 32 + 间隙 1px 等距) */}
<div className={`min-h-0 flex-1 overflow-y-auto ${collapsed ? 'mt-px' : 'mt-3'}`}>
<div className="relative mx-2 mb-1 h-8"> <div className="relative mx-2 mb-1 h-8">
<div className="sidebar-copy absolute inset-0"> <div className="sidebar-copy absolute inset-0">
<button <button
@@ -1,23 +1,21 @@
import { useEffect, useState, type JSX, type ReactNode } from 'react' import { useEffect, useState, type JSX } from 'react'
import { LuPanelRight } from 'react-icons/lu' import { LuPanelRight } from 'react-icons/lu'
/** /**
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。 * 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
* 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」); * 整条是拖拽区;左上角展示当前会话/插件名称(无名称时显示「新会话」);
* title 之后可挂 extra 槽(插件状态胶囊);右侧自绘窗口控制按钮(最小化/ * 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
* 最大化还原/关闭),高度 40px。不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。 * 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
* (插件悬浮状态胶囊不再挂这里:改悬浮于 main 区域右上角。)
*/ */
export function TitleBar({ export function TitleBar({
title, title,
panelOpen, panelOpen,
onTogglePanel, onTogglePanel
extra
}: { }: {
title?: string title?: string
panelOpen: boolean panelOpen: boolean
onTogglePanel: () => void onTogglePanel: () => void
/** 标题右侧插槽(插件状态胶囊等;聊天/插件视图下均可见) */
extra?: ReactNode
}): JSX.Element { }): JSX.Element {
const name = title?.trim() const name = title?.trim()
const [maximized, setMaximized] = useState(false) const [maximized, setMaximized] = useState(false)
@@ -27,7 +25,6 @@ export function TitleBar({
return ( return (
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]"> <div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-line bg-surface pl-4 [-webkit-app-region:drag]">
<div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div> <div className="min-w-0 truncate text-[12.5px] font-medium text-ink">{name || '新会话'}</div>
{extra && <div className="ml-3 flex shrink-0 items-center [-webkit-app-region:no-drag]">{extra}</div>}
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]"> <div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
<button <button
type="button" type="button"
@@ -384,8 +384,9 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
)} )}
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形。 {/* 页面区域:原生 WebContentsView 对齐到这里的矩形。
* 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */} * 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白。
<div className="relative min-h-0 flex-1 bg-page"> * 左内缩 4px:原生视图盖住一切 DOM,必须让出侧面板左缘的拖拽把手 */}
<div className="relative min-h-0 flex-1 bg-page pl-1">
{activeTab && !slotHidden ? ( {activeTab && !slotHidden ? (
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} /> <BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
) : activeTab ? ( ) : activeTab ? (
@@ -0,0 +1,329 @@
/**
* 悬浮状态胶囊(B3 槽位,复刻 ZCode「最小化药丸 ⇄ 展开面板」二态):
* - 所有插件共用一个胶囊,数据同源 plugins:uiState(api.ui.setStatusItem);
* 挂在 main 区域右上角悬浮(侧面板打开时自动左移让位,由挂载点控制)
* - 宿主为每条状态记录 updatedAt:最小化药丸展示「插件最新的消息」
* (tone 点 + 插件图标 + 文本 + 总数角标),点击展开面板
* - 展开面板:插件区域头 = 插件名(第一个区域的右侧是收回药丸的 ⤡);
* 区域内 detail 的**分组是手风琴**——默认收起只显示「标题 + 行尾徽标」,
* 点击展开 rows(ZCode 式:计划 ›);无标题分组直接平铺 rows
* - 面板高度固定,内容溢出滚动;点击面板外部 / Esc 收回最小化
*/
import { useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { LuChevronDown, LuChevronRight, LuMinimize2 } from 'react-icons/lu'
import type { PluginInfo, PluginStatusEntry } from '@shared/types'
import type { StatusDetailRow } 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 []
}
}
/** 明细行(数据驱动:图标 + 标签 + 次级说明 + 行尾彩色数值,行内可深链) */
function StatusRows({
rows,
onOpenView
}: {
rows: StatusDetailRow[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
return (
<>
{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>
)
})}
</>
)
}
export function PluginFloatingCapsule({
entries,
plugins,
onOpenView
}: {
entries: PluginStatusEntry[]
plugins: PluginInfo[]
onOpenView: (view: string, params?: unknown) => void
}): ReactNode {
const [open, setOpen] = useState(false)
const [collapsedAreas, setCollapsedAreas] = useState<string[]>(loadCollapsedSections)
/** 分组手风琴(展开的 section 键 pluginId:itemId:idx;默认收起,会话内记忆) */
const [openSections, setOpenSections] = useState<Set<string>>(() => new Set())
const rootRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
localStorage.setItem(SECTION_STORAGE_KEY, JSON.stringify(collapsedAreas))
}, [collapsedAreas])
// 面板开着时:点击外部 / 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 toggleArea = (pluginId: string): void => {
setCollapsedAreas((prev) =>
prev.includes(pluginId) ? prev.filter((id) => id !== pluginId) : [...prev, pluginId]
)
}
const toggleSection = (key: string): void => {
setOpenSections((prev) => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
if (!open) {
return (
<div ref={rootRef} className="relative shrink-0">
<button
type="button"
title="插件状态(点击展开)"
onClick={() => setOpen(true)}
className="flex h-7 max-w-[88px] shrink-0 items-center gap-1.5 rounded-full border border-line bg-surface px-2.5 text-[11.5px] text-ink-2 shadow-card 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">
{/* 最大高度封顶,高度随内容自适应;溢出才滚动 */}
<div className="max-h-[min(480px,58vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5">
{[...byPlugin.entries()].map(([pluginId, pluginEntries], areaIdx) => {
const plugin = plugins.find((p) => p.manifest.id === pluginId)
const areaCollapsed = collapsedAreas.includes(pluginId)
return (
<div key={pluginId} className={areaIdx > 0 ? 'mt-1 border-t border-line pt-1' : ''}>
{/* 区域头 = 插件名(收拢在左侧);⤡ 收起按钮贴面板最右缘(仅第一个区域) */}
<div className="flex w-full items-center gap-0.5 px-1 py-0.5">
<button
type="button"
aria-expanded={!areaCollapsed}
onClick={() => toggleArea(pluginId)}
className="flex min-w-0 items-center gap-1.5 rounded-control px-1.5 py-1 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 truncate text-[12.5px] font-medium text-ink">
{plugin?.manifest.name ?? pluginId}
</span>
<LuChevronDown
size={12}
aria-hidden
className={`shrink-0 text-ink-3 transition-transform duration-150 ${areaCollapsed ? '-rotate-90' : ''}`}
/>
</button>
{areaIdx === 0 && (
<button
type="button"
aria-label="收起到悬浮胶囊"
title="收起"
onClick={() => setOpen(false)}
className="ml-auto shrink-0 rounded-control p-1.5 text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
>
<LuMinimize2 size={13} />
</button>
)}
</div>
{!areaCollapsed && (
<div className="pb-1">
{pluginEntries.map((entry) => {
const clickable = Boolean(entry.item.openView)
return (
<div key={entry.itemId}>
{entry.item.detail ? (
// 有详情:分组手风琴(默认收起,标题行 + 行尾徽标;无标题分组平铺)
entry.item.detail.sections.map((section, si) => {
if (!section.title) {
return <StatusRows key={si} rows={section.rows} onOpenView={onOpenView} />
}
const key = `${pluginId}:${entry.itemId}:${si}`
const expanded = openSections.has(key)
return (
<div key={key}>
<button
type="button"
aria-expanded={expanded}
onClick={() => toggleSection(key)}
className="flex w-full items-center gap-2 rounded-control px-2 py-1.5 text-left transition-colors hover:bg-hover"
>
<span className="min-w-0 flex-1 truncate text-[12px] leading-4 text-ink">
{section.title}
</span>
{section.trailing && (
<span
className={`shrink-0 text-[11.5px] font-medium ${
TRAILING_TONE_CLASS[section.trailingTone ?? 'default']
}`}
>
{section.trailing}
</span>
)}
<LuChevronRight
size={12}
aria-hidden
className={`shrink-0 text-ink-3 transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
</button>
{expanded && (
<div className="pb-1 pl-1">
<StatusRows rows={section.rows} onOpenView={onOpenView} />
</div>
)}
</div>
)
})
) : (
// 纯文本条目行
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"
>
<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>
</button>
) : (
<div className="flex w-full items-center gap-2 rounded-control px-2 py-1.5">
<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>
</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>
)
}
+2
View File
@@ -406,6 +406,8 @@ export interface PluginStatusEntry {
pluginId: string pluginId: string
itemId: string itemId: string
item: StatusItem item: StatusItem
/** 最近一次写入时间(宿主记录;悬浮胶囊据此展示"插件最新消息") */
updatedAt: number
} }
/** 工具栏按钮条目 */ /** 工具栏按钮条目 */
+1 -2
View File
@@ -181,11 +181,10 @@ api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮
| 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ | | 侧栏入口(B1) | manifest icon/name/order;点进插件整页视图 | ✅ |
| 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ | | 插件整页 Main | 内置组件级 / 外置 renderer 组件 / 外置 iframe;聊天列 display:none 保活 | ✅ |
| 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ | | 面板 tab(B6) | `panel: true`;SidePanel 零改动承载(内置组件 / 外置 renderer 组件 / iframe) | ✅ |
| 状态胶囊(B2) | `setStatusItem` 数据驱动,TitleBar 渲染,两种视图均可见;顶栏为紧凑状态条,点击可展开宿主渲染的详情弹层(`detail`:分组/行/徽标/行内深链) | ✅ | | 状态胶囊(B2+B3) | `setStatusItem` 数据驱动,**悬浮胶囊**挂 main 区右上角(ZCode 复刻):最小化药丸展示最新一条消息(宿主记录 updatedAt),展开面板头即插件名、`detail` 分组为手风琴(默认收起显示 标题+trailing 徽标,点击展开行)、最大高度随内容自适应、行尾彩色数值与行内深链 | ✅ |
| 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ | | 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ |
| 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 | | 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 |
| 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 | | 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 |
| 聊天区浮动胶囊(B3) | 独立浮动态暂缓——顶栏胶囊的详情面板(ZCode 式)已覆盖同场景;数据同源(`setStatusItem`) | ⏳ |
## 交互基线 ## 交互基线
+8 -5
View File
@@ -2,12 +2,15 @@
> 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。 > 前置:[L0](./01-hello-plugin.md)。宿主开放 5 个数据驱动 UI 槽位 + 1 个 iframe 页形态。**数据驱动**意味着插件只提交数据,渲染与样式由宿主统一负责——这是 UI 一致性的第一道保障(自定义视觉见 [DESIGN.md](./DESIGN.md) 与 [L4](./05-renderer-components.md))。
## 1. 状态胶囊(TitleBar 顶部) ## 1. 状态胶囊(悬浮胶囊:最小化药丸 ⇄ 展开面板)
顶栏胶囊是**紧凑状态条**,点击行为三档: 所有插件**共用一个悬浮胶囊**,悬浮于 main 区域右上角(侧面板打开时自动左移让位;ZCode 复刻)。插件照常 `setStatusItem`,宿主记录每次写入时间,两种形态:
- **最小化药丸**:展示「最新的消息」(全部插件中 `updatedAt` 最新的一条:tone 点 + 插件图标 + 文本;多于一条时带总数角标),点击展开面板;
- **展开面板**:面板头就是插件名(第一个区域的右侧是收回药丸的 ⤡);多个插件时各占一个区域、区域头可收起/展开(记忆跨启动)。区域内 `detail` 的**分组是手风琴**——默认收起只显示「分组标题 + 行尾徽标」,点击展开 rows(ZCode 式「计划 ›」);无标题分组直接平铺。面板最大高度封顶(min(480px,58vh)),高度随内容自适应,溢出才滚动;点击面板外部 / Esc 收回药丸。
```js ```js
// ① 纯展示 // ① 纯展示(只出现在药丸/区域行)
api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' }) api.ui.setStatusItem('conn', { text: '已连接 · 3 任务' })
// ② 点击深链 // ② 点击深链
@@ -18,12 +21,12 @@ api.ui.setStatusItem('conn', {
openView: { view: 'my-plugin' } // 点击跳到插件整页视图 openView: { view: 'my-plugin' } // 点击跳到插件整页视图
}) })
// ③ 点击展开详情弹层(声明了 detail 时优先于 openView) // ③ 携详情(分组 = 手风琴:收起显示 标题+trailing 徽标,点击展开 rows)
api.ui.setStatusItem('tasks', { api.ui.setStatusItem('tasks', {
text: '任务 2/5', text: '任务 2/5',
tone: 'accent', tone: 'accent',
detail: { detail: {
title: '任务概览', // 缺省用胶囊文本 title: '任务概览', // 面板中省略(区域头已是插件名)
sections: [ sections: [
{ {
title: '进行中', // 分组标题可省略 title: '进行中', // 分组标题可省略
+3 -3
View File
@@ -12,8 +12,8 @@
| v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) | | v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) |
| v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 | | v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 |
| v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 | | v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 |
| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、状态胶囊详情面板(ZCode Git 工具式:头部+分组+行尾彩色数值,B3 浮动态并入顶栏胶囊)、图标统一 lucide 白名单、面板平铺标签条 | | v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data/<id>/data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、图标统一 lucide 白名单、面板平铺标签条 |
| 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 | | 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 |
--- ---
@@ -29,7 +29,7 @@
**自省**(v0.8.0 → 指导 M4+): **自省**(v0.8.0 → 指导 M4+):
1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。 1. **外置插件尚未强制签名**——当前 `userData/plugins/` 直接装载(代码里已留 TODO);M5 验签落地前不对外分发任何外置插件。
2. **B3/B4/B5 槽位数据已通、渲染未接**——状态胶囊已在 TitleBar 生效;Composer 工具行按钮、聊天流围栏块元组件(data-table 等)、聊天区浮动胶囊排 M4 同批或下版本。 2. **B4/B5 槽位数据已通、渲染未接**——状态胶囊已升级为悬浮胶囊(B3 落地:药丸⇄面板);Composer 工具行按钮、聊天流围栏块元组件(data-table 等)排 M4 同批或下版本。
3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。 3. **切换回聊天未自动聚焦输入框**——保活后焦点丢失,需 Composer 暴露 focus 入口(data 属性 + App effect)。
4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。 4. **插件管理 UI 仍占位**——SettingsModal"插件"tab 转正(列表/启停/重载/导入)排 M4。
5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。 5. **TS7(typescript 7 native)增量缓存坑**——改 `declare global` 后 typecheck 可能读到旧程序,删 `out/tsconfig.{node,web}/` 下 tsbuildinfo 即恢复。
+6 -2
View File
@@ -28,10 +28,14 @@ export interface StatusDetailRow {
openView?: OpenViewAction openView?: OpenViewAction
} }
/** 详情弹层分组 */ /** 详情弹层分组(悬浮面板中为手风琴:默认收起只显示标题行,点击展开 rows) */
export interface StatusDetailSection { export interface StatusDetailSection {
/** 分组标题(如「Git 工具」「计划」);缺省无标题 */ /** 分组标题(如「计划」「进程」);缺省无标题(rows 直接平铺、不可收起) */
title?: string title?: string
/** 标题行尾徽标(如「4/4」「35分7秒」;收起态也可见——ZCode 式摘要) */
trailing?: string
/** 行尾徽标色调,缺省 default */
trailingTone?: StatusTone
rows: StatusDetailRow[] rows: StatusDetailRow[]
} }