diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index 2fb1c14..63bc229 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -22,6 +22,7 @@ import { } from './plugins/renderer-host' import { LoadingState, ErrorState } from '@aso/plugin-sdk/ui' import { PluginErrorBoundary } from './plugins/PluginErrorBoundary' +import { PluginFloatingCapsules } from './plugins/PluginFloatingCapsules' import { PluginStatusCapsules } from './plugins/PluginStatusCapsules' import { usePluginList, usePluginsUiState, sidebarPlugins } from './plugins/store' import { BUILTIN_PLUGIN_COMPONENTS } from './plugins/builtin-components' @@ -456,6 +457,7 @@ export default function App(): JSX.Element { onPickSuggestion={handlePickSuggestion} onQuoteSelection={handleQuoteSelection} /> + void +}): ReactNode { + const [open, setOpen] = useState(false) + const [detailKey, setDetailKey] = useState(null) + const rootRef = useRef(null) + + // 展开时:点外部 / Esc 收起 + useEffect(() => { + if (!open) return + const onDown = (e: MouseEvent): void => { + if (rootRef.current && !rootRef.current.contains(e.target as Node)) { + setOpen(false) + setDetailKey(null) + } + } + const onKey = (e: KeyboardEvent): void => { + if (e.key === 'Escape') { + setOpen(false) + setDetailKey(null) + } + } + document.addEventListener('mousedown', onDown) + document.addEventListener('keydown', onKey) + return () => { + document.removeEventListener('mousedown', onDown) + document.removeEventListener('keydown', onKey) + } + }, [open]) + + // 条目集合变化(停用/重载/删除)时收起;按 key 序列比较,避免引用变化误关 + const entryKeys = entries.map((e) => `${e.pluginId}:${e.itemId}`).join(',') + useEffect(() => { + setOpen(false) + setDetailKey(null) + }, [entryKeys]) + + if (entries.length === 0) return null + const shown = entries.slice(0, 4) + return ( +
+ {open && + shown.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 detailOpen = detailKey === key + return ( +
+ + {detailOpen && item.detail && ( + + )} +
+ ) + })} + +
+ ) +} diff --git a/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx b/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx index 7f25779..f21a420 100644 --- a/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx +++ b/apps/desktop/src/renderer/src/plugins/PluginStatusCapsules.tsx @@ -11,7 +11,7 @@ import type { ReactNode } from 'react' import type { PluginStatusEntry } from '@shared/types' import { PluginIconValue } from './plugin-icons' -const TONE_CLASS: Record = { +export const TONE_CLASS: Record = { default: 'border-line text-ink-2', accent: 'border-accent/40 text-accent-ink bg-accent-tint', warn: 'border-orange/40 text-orange bg-orange-tint', @@ -25,11 +25,23 @@ const BADGE_TONE_CLASS: Record = { danger: 'bg-red-tint text-red' } -/** 详情弹层:分组列表,行数据自带动线 */ -function StatusPopover({ entry, onOpenView }: { entry: PluginStatusEntry; onOpenView: (view: string, params?: unknown) => void }): ReactNode { +/** 详情弹层:分组列表,行数据自带深链(B2 顶栏向下弹、B3 浮动胶囊向上弹) */ +export function StatusPopover({ + entry, + onOpenView, + direction = 'down' +}: { + entry: PluginStatusEntry + onOpenView: (view: string, params?: unknown) => void + direction?: 'down' | 'up' +}): ReactNode { const { item } = entry return ( -
+
{item.detail?.title ?? item.text}
{item.detail?.sections.map((section, si) => ( diff --git a/docs/plugins.md b/docs/plugins.md index e05be6f..9b7bad0 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -174,7 +174,7 @@ api.ui.onSettingsAction((key) => {}) // button 条目点击回调(key = 按钮 | 深链 | `openView(view, params)`(main 侧 IPC 广播 / 页侧桥直通) | ✅ | | 工具栏按钮(B5) | `registerToolbarButton` 数据已通,Composer 工具行渲染 | ⏳ 下版本 | | 聊天流富组件(B4) | ```类型``` 围栏块 → 宿主元组件(data-table/stat-grid/step-list/kv-card) | ⏳ 下版本 | -| 聊天区浮动胶囊(B3) | 与 B2 同源数据,折叠/展开双态 | ⏳ 下版本 | +| 聊天区浮动胶囊(B3) | 与 B2 同源数据:聊天列右下角折叠圆钮(计数徽标),展开为纵向胶囊堆栈,detail 弹层/深链与 B2 一致 | ✅ | ## 交互基线 diff --git a/docs/roadmap.md b/docs/roadmap.md index ae8bcdc..4e62f5d 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ | v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) | | v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 | | 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 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check) | +| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、状态胶囊详情弹层+聊天区浮动胶囊(B3,与 B2 同源)、图标统一 lucide 白名单、面板平铺标签条 | | 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B3/B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 | ---