mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-28 01:11:31 +08:00
✨ feat(ai-entry): 优化AI助手贴边入口交互体验
- 将 AI 助手入口从侧栏工具区迁移为主内容区右侧贴边标签 - 调整打开态贴边标签锚点到面板左外沿,避免遮挡头部操作区 - 重排侧栏顶部工具布局,恢复四项按钮的稳定网格结构 - 新增 aiEntryLayout 布局辅助与回归测试,覆盖打开态附着位置
This commit is contained in:
87
docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md
Normal file
87
docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# AI Edge Handle Entry Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将 AI 助手入口从标题栏移除,改为主内容区右侧的 `A2` 贴边标签入口,并保持现有 AI 面板开关与聊天逻辑不变。
|
||||
|
||||
**Architecture:** 保留现有 `App.tsx` 作为 AI 入口装配点,不改 AI 面板展开方向和 store 状态。通过 `aiEntryLayout.ts` 承载可测试的布局/样式决策,让 `App.tsx` 只负责在“主内容关闭态”和“面板展开态”两个壳层里挂载同一个贴边标签。
|
||||
|
||||
**Tech Stack:** React 18, TypeScript, Ant Design, Vitest, Vite
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
- Modify: `frontend/src/App.tsx`
|
||||
- 移除标题栏 AI 入口渲染与相关样式状态
|
||||
- 在主内容区右边缘挂载关闭态入口
|
||||
- 在 AI 面板外沿挂载打开态入口
|
||||
- Modify: `frontend/src/utils/aiEntryLayout.ts`
|
||||
- 将现有标题栏专用布局 helper 改为右侧贴边标签 helper
|
||||
- 保留 `SIDEBAR_UTILITY_ITEM_KEYS`
|
||||
- Modify: `frontend/src/utils/aiEntryLayout.test.ts`
|
||||
- 先写失败测试,锁定入口位置、附着位置和贴边标签样式约束
|
||||
- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md`
|
||||
- 同步“设计已切换到右侧贴边标签方案”和后续验证状态
|
||||
|
||||
## Chunk 1: Layout Contract Regression
|
||||
|
||||
### Task 1: 把布局辅助契约改成“右侧贴边标签”
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/utils/aiEntryLayout.test.ts`
|
||||
- Modify: `frontend/src/utils/aiEntryLayout.ts`
|
||||
|
||||
- [ ] **Step 1: 写失败测试,锁定新的布局语义**
|
||||
- [ ] **Step 2: 运行测试,确认它因旧契约而失败**
|
||||
- [ ] **Step 3: 用最小改动实现新的 helper 契约**
|
||||
- [ ] **Step 4: 重新运行测试,确认 helper 契约变绿**
|
||||
- [ ] **Step 5: 提交这一小块**
|
||||
|
||||
## Chunk 2: Remove Titlebar Entry
|
||||
|
||||
### Task 2: 从标题栏彻底移除 AI 入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: 写出本次要删除的标题栏入口代码清单**
|
||||
- [ ] **Step 2: 删除标题栏 AI 入口相关状态与 JSX**
|
||||
- [ ] **Step 3: 运行前端构建,确认清理后没有遗留引用**
|
||||
- [ ] **Step 4: 提交标题栏清理**
|
||||
|
||||
## Chunk 3: Mount The Edge Handle
|
||||
|
||||
### Task 3: 在主内容区右边缘挂载关闭态贴边标签
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.tsx`
|
||||
- Modify: `frontend/src/utils/aiEntryLayout.ts`
|
||||
|
||||
- [ ] **Step 1: 为关闭态入口写最小布局决策代码**
|
||||
- [ ] **Step 2: 把主内容横向容器改成可承载绝对定位标签**
|
||||
- [ ] **Step 3: 运行构建,确认关闭态入口接入不破坏布局**
|
||||
- [ ] **Step 4: 提交关闭态入口挂载**
|
||||
|
||||
### Task 4: 打开 AI 面板时让标签贴住面板外沿
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/App.tsx`
|
||||
|
||||
- [ ] **Step 1: 给 AI 面板外层加一个仅负责贴边标签定位的壳层**
|
||||
- [ ] **Step 2: 用附着位置决策保证开关态只渲染一个入口**
|
||||
- [ ] **Step 3: 重新运行 helper 测试和构建**
|
||||
- [ ] **Step 4: 提交打开态连续关系**
|
||||
|
||||
## Chunk 4: Manual Verification And Docs
|
||||
|
||||
### Task 5: 手工验证空间关系,再更新追踪文档
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md`
|
||||
|
||||
- [ ] **Step 1: 启动本地开发环境做手工验证**
|
||||
- [ ] **Step 2: 如发现重叠,优先调整偏移而不是改回胶囊按钮**
|
||||
- [ ] **Step 3: 运行最终验证命令**
|
||||
- [ ] **Step 4: 更新需求追踪文档**
|
||||
- [ ] **Step 5: 提交收尾文档与验证结果**
|
||||
158
docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md
Normal file
158
docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# AI 助手入口重设计:右侧贴边标签方案
|
||||
|
||||
## 1. 背景
|
||||
|
||||
当前 AI 助手入口已经尝试过两种方案:
|
||||
|
||||
1. 标题栏右侧、靠近窗口控制按钮的胶囊按钮
|
||||
2. 标题栏左侧、紧跟应用标题的胶囊按钮
|
||||
|
||||
这两种方案都存在同一个问题:入口仍然被感知为“标题栏按钮”或“全局工具按钮”,而不是“从右侧展开的协作面板入口”。用户已经明确选择将 AI 助手定义为协作入口,而非标题栏功能按钮或左侧工作流工具。
|
||||
|
||||
本次设计目标是重新定义 AI 助手入口的空间归属关系,让入口与右侧 AI 面板形成直接、自然的视觉和交互映射。
|
||||
|
||||
## 2. 设计目标
|
||||
|
||||
- 让 AI 入口看起来像右侧面板的入口,而不是标题栏按钮
|
||||
- 让入口位置与 AI 面板展开方向保持一致
|
||||
- 降低入口对标题栏和主工作区视觉层级的干扰
|
||||
- 保持现有 AI 面板开关逻辑、面板位置、聊天状态和快捷键不变
|
||||
- 保持左侧 `Sider` 顶部工具组为四个常规工具入口,不再放回 AI
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不修改 AI 面板本体的信息架构
|
||||
- 不修改 AI 聊天、会话、模型、上下文逻辑
|
||||
- 不新增 AI 专属状态存储字段
|
||||
- 不重新设计标题栏
|
||||
- 不把 AI 面板改成左侧抽屉或底部抽屉
|
||||
|
||||
## 4. 已评估方案
|
||||
|
||||
### 方案 A:标题栏入口
|
||||
|
||||
把 AI 放在标题栏右侧或标题后方。
|
||||
|
||||
结论:放弃。
|
||||
|
||||
原因:
|
||||
|
||||
- 容易被识别成窗口级按钮,而不是右侧协作面板入口
|
||||
- 入口与 AI 面板的空间关系过远
|
||||
- 标题栏承担拖拽、双击、窗口控制等系统职责,额外入口容易显得突兀
|
||||
|
||||
### 方案 B:内容区悬浮胶囊
|
||||
|
||||
把 AI 入口放在内容区右上角,作为脱离标题栏的悬浮按钮。
|
||||
|
||||
结论:未采用。
|
||||
|
||||
原因:
|
||||
|
||||
- 比标题栏好,但仍然像“漂浮的按钮”
|
||||
- 视觉上还是偏独立动作,而不是面板把手
|
||||
- 容易与未来内容区工具条或右上角操作区冲突
|
||||
|
||||
### 方案 C:右侧贴边入口
|
||||
|
||||
把 AI 入口挂在主内容区右边缘、贴近 AI 面板出现位置。
|
||||
|
||||
结论:采用。
|
||||
|
||||
原因:
|
||||
|
||||
- 最符合“右侧面板从这里展开”的直觉
|
||||
- 可以脱离标题栏和窗口控制区
|
||||
- 与 AI 面板的视觉关系最强
|
||||
- 更符合“协作入口”而不是“工具按钮”的产品定位
|
||||
|
||||
## 5. 最终方案
|
||||
|
||||
### 5.1 位置
|
||||
|
||||
- AI 入口彻底离开标题栏
|
||||
- AI 入口不再出现在左侧 `Sider` 顶部工具组
|
||||
- 入口挂载在主内容区容器内部,而不是窗口根节点
|
||||
- 位置锚点是主内容区最右侧边缘,即现有 AI 面板展开出来的那条边
|
||||
- 默认位于内容区右上方,明显低于标题栏底边,避免再与标题栏形成视觉混淆
|
||||
|
||||
### 5.2 形态
|
||||
|
||||
选定形态为 `A2:上角贴边标签`。
|
||||
|
||||
入口外观定义如下:
|
||||
|
||||
- 采用“半嵌入式贴边标签”而不是完整胶囊按钮
|
||||
- 标签右侧贴边,左侧圆角外露
|
||||
- 默认文案收敛为 `AI`
|
||||
- 保留一个轻量机器人图标,用于快速识别
|
||||
- 视觉风格使用面板边框色、弱背景和轻圆角
|
||||
- 不使用强阴影、不使用主按钮视觉、不做明显浮层感
|
||||
|
||||
目标效果是:入口更像右侧协作面板的标签,而不是单独悬浮的交互控件。
|
||||
|
||||
### 5.3 状态
|
||||
|
||||
关闭态:
|
||||
|
||||
- 标签保持低存在感
|
||||
- 弱背景、弱边框、常规文字色
|
||||
- 保证可发现,但不抢主内容注意力
|
||||
|
||||
悬停态:
|
||||
|
||||
- 仅做轻微提亮
|
||||
- 允许边框轻微增强
|
||||
- 不放大、不上浮、不出现明显浮层阴影
|
||||
|
||||
打开态:
|
||||
|
||||
- 标签切换到 AI 激活色
|
||||
- 视觉上与右侧 AI 面板顶部外沿形成连续感
|
||||
- 不隐藏入口,让用户明确知道“标签已经展开成右侧这块面板”
|
||||
|
||||
## 6. 交互定义
|
||||
|
||||
- 点击贴边标签时,仍然调用现有 AI 面板开关逻辑
|
||||
- AI 面板继续从右侧展开,不改变方向
|
||||
- 关闭面板后,标签恢复为单独的贴边入口
|
||||
- 打开面板时,标签保留在面板外沿作为“已展开标识”
|
||||
- 当面板宽度被拖动时,标签始终贴在面板外沿,不与主内容区脱节
|
||||
- 在窄窗口下优先保证标签可点击,必要时继续保留 `AI` 简写,不要求展开成长文案
|
||||
|
||||
## 7. 实现边界
|
||||
|
||||
- 主入口显示与定位由 `App.tsx` 管理
|
||||
- AI 面板本身只保留现有展开、关闭和宽度调整行为
|
||||
- 状态继续复用现有 `aiPanelVisible`、`toggleAIPanel`、`setAIPanelVisible`
|
||||
- 不新增持久化字段,不改 store 结构
|
||||
- 左侧 `Sider` 顶部继续保留四个工具按钮:`工具 / 代理 / 主题 / 关于`
|
||||
- 标题栏恢复为纯窗口级区域,不继续承载 AI 入口
|
||||
|
||||
## 8. 验证口径
|
||||
|
||||
实现后重点验证以下事项:
|
||||
|
||||
1. 关闭态时,右侧贴边标签是否自然,不像悬浮按钮
|
||||
2. 打开态时,标签是否与 AI 面板形成清晰连续关系
|
||||
3. 拖动 AI 面板宽度后,标签是否仍贴在面板外沿
|
||||
4. 窄窗口下标签是否仍可点击,不与内容区遮挡
|
||||
5. 标题栏空白区、窗口控制按钮、左侧侧栏工具组是否恢复干净,不再承担 AI 入口职责
|
||||
|
||||
## 9. 风险与注意事项
|
||||
|
||||
- 贴边标签如果做得过大,会重新变成“漂浮按钮”
|
||||
- 贴边标签如果做得过窄,会影响关闭态可发现性
|
||||
- 标签定位需要明确绑定主内容区右边界,而不是整个窗口最右边界,否则在不同布局状态下容易错位
|
||||
- 打开态与面板外沿的连续关系必须处理好,否则会显得像两个无关控件
|
||||
|
||||
## 10. 决策结论
|
||||
|
||||
本次 AI 助手入口重设计的最终结论是:
|
||||
|
||||
- 不再使用标题栏入口
|
||||
- 不回退到左侧工具条入口
|
||||
- 采用主内容区右侧的贴边标签方案
|
||||
- 采用 `A2` 形态,即“上角贴边标签”
|
||||
|
||||
这是当前最符合“协作入口”定位的方案,也是最能解释 AI 面板空间来源的方案。
|
||||
63
docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md
Normal file
63
docs/需求追踪/需求进度追踪-AI助手入口迁移到应用顶栏右侧-20260328.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# 需求进度追踪 - AI助手入口迁移到主内容区右侧贴边标签
|
||||
|
||||
## 1. 需求摘要
|
||||
- 需求名称:AI 助手入口迁移到主内容区右侧贴边标签
|
||||
- 提出日期:2026-03-28
|
||||
- 负责人:Codex
|
||||
- 目标:将 AI 助手入口从左侧 `Sider` 顶部工具条和标题栏中移出,改为主内容区右侧边缘的 `A2` 贴边标签入口,并保持现有 AI 面板开关逻辑不变。
|
||||
- 非目标:不调整 AI 面板展开方向,不修改 AI 聊天逻辑,不新增快捷键或状态存储。
|
||||
|
||||
## 2. 范围与验收
|
||||
- 范围:`App.tsx` 主内容区与 AI 面板装配位置、左侧 `Sider` 顶部工具条、`aiEntryLayout` 布局辅助与相关前端回归测试。
|
||||
- 验收标准:主内容区右侧边缘出现 `A2` 贴边标签入口;标题栏与左侧 `Sider` 顶部都不再显示 AI 入口;按钮点击仍能正常打开/关闭 AI 面板,且打开后标签与面板外沿形成连续关系。
|
||||
- 依赖与约束:不修改 AI 面板展开方向;保持现有 store 结构与聊天逻辑;需兼容窄窗口下的点击区域。
|
||||
|
||||
## 3. 里程碑与进度
|
||||
- [x] 阶段 1(需求澄清):确认 AI 入口应按“协作入口”重新设计
|
||||
- [x] 阶段 2(影响分析):定位标题栏、侧栏工具条与 AI 面板开关实现
|
||||
- [x] 阶段 3(方案设计):完成 `A2` 右侧贴边标签方案设计与评审
|
||||
- [x] 阶段 4(实施计划):输出实现计划文档
|
||||
- [x] 阶段 5(实现与自检):完成 helper 单测与前端构建验证
|
||||
- [ ] 阶段 6(评审与交付):待手工界面确认后整理风险、回滚和验证点
|
||||
- [ ] 阶段 7(发布与观察):待后续观察
|
||||
|
||||
## 4. 变更清单
|
||||
- 已完成:新增 `frontend/src/utils/aiEntryLayout.ts` 与配套单测;左侧 `Sider` 顶部恢复四个工具按钮;AI 入口改为主内容区右侧贴边标签;修复打开 AI 面板后贴边标签压住头部按钮区的问题,打开态现在附着到面板左外沿。
|
||||
- 进行中:无。
|
||||
- 待处理:本机手工确认贴边标签的实际视觉位置、打开态连续关系和窄窗口点击区。
|
||||
|
||||
## 5. 风险与阻塞
|
||||
- 风险:贴边标签如果尺寸过大,会重新变成悬浮按钮;如果过小,会影响关闭态可发现性;如果锚点错误,打开面板后会与外沿脱节。
|
||||
- 阻塞:自动化浏览器手工验证受限于本机 Playwright Chrome 安装失败(权限不足);尝试直接通过 Edge 拉起本地 URL 也被当前执行策略拦截,无法由 Codex 自动代开页面。
|
||||
- 缓解措施:当前已用 helper 单测与 `npm run build` 验证静态接线;界面最终位置仍需你在本机直接运行后再看一眼。
|
||||
|
||||
## 6. 决策记录
|
||||
- 决策 1:AI 入口定位为“协作入口”,而不是标题栏按钮或侧栏工具按钮。
|
||||
- 决策 2:放弃标题栏右侧与标题后入口方案,改为主内容区右侧贴边标签。
|
||||
- 决策 3:选定 `A2` 形态,即上角贴边标签,而不是竖向拉手或极简细窄开关。
|
||||
- 决策 4:左侧 `Sider` 顶部保留四个工具按钮,AI 不再回到该区域。
|
||||
- 决策 5:打开态贴边标签的锚点必须落在 `panel-shell` 左外沿,而不能落在面板右上角内部,否则会与 `AIChatHeader` 操作区重叠。
|
||||
|
||||
## 7. 验证记录
|
||||
- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts`
|
||||
- 结果:通过,`6/6` 用例通过。
|
||||
- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vitest 输出 `1 passed / 6 passed`。
|
||||
|
||||
- 验证项:`npm test -- src/utils/aiEntryLayout.test.ts`(打开态锚点回归)
|
||||
- 结果:先失败后通过;新增打开态外侧附着回归后,最终 `8/8` 用例通过。
|
||||
- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中先得到 `resolveAIEdgeHandleDockStyle is not a function` 的红灯,随后补充 dock style helper 后重新执行,Vitest 输出 `1 passed / 8 passed`。
|
||||
|
||||
- 验证项:`npm run build`
|
||||
- 结果:通过,`tsc && vite build` 退出码为 `0`。
|
||||
- 证据(日志/截图/链接):2026-03-28 在隔离 worktree 中执行,Vite 完成生产构建,存在既有 chunk size warning,但不阻塞本次改动。
|
||||
|
||||
- 手工验证项:浏览器态界面核对
|
||||
- 结果:未完成。
|
||||
- 证据(日志/截图/链接):2026-03-28 16:24:29 +08:00 再次尝试时,Playwright 仍因缺少 Chrome 且安装需要更高权限而失败;尝试通过本机 Edge 打开 `http://localhost:5173` 时又被当前命令执行策略拦截,未能自动采集界面证据。
|
||||
|
||||
- 计划文档:`docs/superpowers/plans/2026-03-28-ai-edge-handle-entry.md`
|
||||
- 设计文档:`docs/superpowers/specs/2026-03-28-ai-edge-handle-entry-design.md`
|
||||
|
||||
## 8. 下一步
|
||||
- 下一步行动:当前实现先保留在隔离 worktree `C:\Users\yangguofeng\.config\superpowers\worktrees\GoNavi\ai-edge-handle-entry`,待你本机目测确认后,再决定是否合并回 `dev`。
|
||||
- 负责人:Codex
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
isShortcutMatch,
|
||||
normalizeShortcutCombo,
|
||||
} from './utils/shortcuts';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
resolveAIEdgeHandleAttachment,
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './utils/aiEntryLayout';
|
||||
import { ConfigureGlobalProxy, SetMacNativeWindowControls, SetWindowTranslucency } from '../wailsjs/go/app/App';
|
||||
import './App.css';
|
||||
|
||||
@@ -1125,6 +1132,61 @@ function App() {
|
||||
const [capturingShortcutAction, setCapturingShortcutAction] = useState<ShortcutAction | null>(null);
|
||||
const [isProxyModalOpen, setIsProxyModalOpen] = useState(false);
|
||||
const [isAISettingsOpen, setIsAISettingsOpen] = useState(false);
|
||||
const aiEntryPlacement = resolveAIEntryPlacement();
|
||||
const aiEdgeHandleAttachment = resolveAIEdgeHandleAttachment(aiPanelVisible);
|
||||
const aiEdgeHandleDockStyle = useMemo(
|
||||
() => resolveAIEdgeHandleDockStyle(aiEdgeHandleAttachment),
|
||||
[aiEdgeHandleAttachment],
|
||||
);
|
||||
const aiEdgeHandleStyle = useMemo(() => (
|
||||
resolveAIEdgeHandleStyle({
|
||||
darkMode,
|
||||
aiPanelVisible,
|
||||
effectiveUiScale,
|
||||
})
|
||||
), [aiPanelVisible, darkMode, effectiveUiScale]);
|
||||
const sidebarUtilityItems = useMemo(() => {
|
||||
const itemMap = {
|
||||
tools: {
|
||||
key: 'tools',
|
||||
title: '工具',
|
||||
icon: <ToolOutlined />,
|
||||
onClick: () => setIsToolsModalOpen(true),
|
||||
},
|
||||
proxy: {
|
||||
key: 'proxy',
|
||||
title: '代理',
|
||||
icon: <GlobalOutlined />,
|
||||
onClick: () => setIsProxyModalOpen(true),
|
||||
},
|
||||
theme: {
|
||||
key: 'theme',
|
||||
title: '主题',
|
||||
icon: <SkinOutlined />,
|
||||
onClick: () => setIsThemeModalOpen(true),
|
||||
},
|
||||
about: {
|
||||
key: 'about',
|
||||
title: '关于',
|
||||
icon: <InfoCircleOutlined />,
|
||||
onClick: () => setIsAboutOpen(true),
|
||||
},
|
||||
} as const;
|
||||
|
||||
return SIDEBAR_UTILITY_ITEM_KEYS.map((key) => itemMap[key]);
|
||||
}, []);
|
||||
const renderAIEdgeHandle = () => (
|
||||
<Tooltip title="AI 助手">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={toggleAIPanel}
|
||||
style={aiEdgeHandleStyle}
|
||||
>
|
||||
AI
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
|
||||
// Log Panel: 最小高度按“工具栏 + 1 条日志行(微增)”限制
|
||||
@@ -1634,24 +1696,12 @@ function App() {
|
||||
>
|
||||
<div style={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
<div style={{ padding: `12px ${sidebarHorizontalPadding}px 8px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<Tooltip title="工具"><Button type="text" icon={<ToolOutlined />} style={utilityButtonStyle} onClick={() => setIsToolsModalOpen(true)} /></Tooltip>
|
||||
<Tooltip title="代理"><Button type="text" icon={<GlobalOutlined />} style={utilityButtonStyle} onClick={() => setIsProxyModalOpen(true)} /></Tooltip>
|
||||
<Tooltip title="主题"><Button type="text" icon={<SkinOutlined />} style={utilityButtonStyle} onClick={() => setIsThemeModalOpen(true)} /></Tooltip>
|
||||
<Tooltip title="关于"><Button type="text" icon={<InfoCircleOutlined />} style={utilityButtonStyle} onClick={() => setIsAboutOpen(true)} /></Tooltip>
|
||||
<div style={{ width: 1, height: 16, background: 'rgba(128,128,128,0.2)', margin: '0 4px' }} />
|
||||
<Tooltip title="AI 助手">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<RobotOutlined />}
|
||||
onClick={toggleAIPanel}
|
||||
style={{
|
||||
...utilityButtonStyle,
|
||||
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : utilityButtonStyle.color,
|
||||
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.16)' : 'rgba(24,144,255,0.12)') : 'transparent'
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 8, width: '100%' }}>
|
||||
{sidebarUtilityItems.map((item) => (
|
||||
<Tooltip key={item.key} title={item.title}>
|
||||
<Button type="text" icon={item.icon} style={utilityButtonStyle} onClick={item.onClick} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ padding: `0 ${sidebarHorizontalPadding}px 10px`, borderBottom: 'none', display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
@@ -1760,12 +1810,24 @@ function App() {
|
||||
/>
|
||||
</Sider>
|
||||
<Content style={{ background: isLogPanelOpen ? bgContent : 'transparent', overflow: 'hidden', display: 'flex', flexDirection: 'column', minWidth: 0, flex: 1 }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'row', position: 'relative' }}>
|
||||
<div style={{ flex: 1, minHeight: 0, minWidth: 0, overflow: 'hidden', display: 'flex', flexDirection: 'column', background: bgContent, marginBottom: isLogPanelOpen ? 8 : 0, borderRadius: isLogPanelOpen ? windowCornerRadius : 0, clipPath: isLogPanelOpen ? `inset(0 round ${windowCornerRadius}px)` : 'none' }}>
|
||||
<TabManager />
|
||||
</div>
|
||||
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'content-shell' && (
|
||||
<div style={aiEdgeHandleDockStyle}>
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
{aiPanelVisible && (
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
|
||||
<div style={{ position: 'relative', display: 'flex', flexShrink: 0, overflow: 'visible' }}>
|
||||
{aiEntryPlacement === 'content-edge' && aiEdgeHandleAttachment === 'panel-shell' && (
|
||||
<div style={aiEdgeHandleDockStyle}>
|
||||
{renderAIEdgeHandle()}
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanel darkMode={darkMode} bgColor={bgContent} onClose={() => setAIPanelVisible(false)} onOpenSettings={() => setIsAISettingsOpen(true)} overlayTheme={overlayTheme} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isLogPanelOpen && (
|
||||
|
||||
71
frontend/src/utils/aiEntryLayout.test.ts
Normal file
71
frontend/src/utils/aiEntryLayout.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
SIDEBAR_UTILITY_ITEM_KEYS,
|
||||
resolveAIEntryPlacement,
|
||||
resolveAIEdgeHandleAttachment,
|
||||
resolveAIEdgeHandleDockStyle,
|
||||
resolveAIEdgeHandleStyle,
|
||||
} from './aiEntryLayout';
|
||||
|
||||
describe('ai entry layout', () => {
|
||||
it('keeps the sidebar utility group free of the AI entry', () => {
|
||||
expect(SIDEBAR_UTILITY_ITEM_KEYS).toEqual(['tools', 'proxy', 'theme', 'about']);
|
||||
});
|
||||
|
||||
it('anchors the AI entry to the content edge', () => {
|
||||
expect(resolveAIEntryPlacement()).toBe('content-edge');
|
||||
});
|
||||
|
||||
it('attaches the closed handle to the content shell', () => {
|
||||
expect(resolveAIEdgeHandleAttachment(false)).toBe('content-shell');
|
||||
});
|
||||
|
||||
it('attaches the open handle to the panel shell', () => {
|
||||
expect(resolveAIEdgeHandleAttachment(true)).toBe('panel-shell');
|
||||
});
|
||||
|
||||
it('keeps the closed handle docked on the content edge', () => {
|
||||
expect(resolveAIEdgeHandleDockStyle('content-shell')).toMatchObject({
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: 0,
|
||||
zIndex: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the open handle outside the panel shell to avoid header overlap', () => {
|
||||
expect(resolveAIEdgeHandleDockStyle('panel-shell')).toMatchObject({
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: '100%',
|
||||
zIndex: 12,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the attached active appearance when the AI panel is open', () => {
|
||||
const style = resolveAIEdgeHandleStyle({
|
||||
darkMode: true,
|
||||
aiPanelVisible: true,
|
||||
effectiveUiScale: 1,
|
||||
});
|
||||
|
||||
expect(style.color).toBe('#ffd666');
|
||||
expect(style.background).toBe('rgba(255,214,102,0.12)');
|
||||
expect(style.borderRadius).toBe('10px 0 0 10px');
|
||||
expect(style.borderRight).toBe('none');
|
||||
expect(style.height).toBe(24);
|
||||
});
|
||||
|
||||
it('uses the subdued attached appearance when the AI panel is closed', () => {
|
||||
const style = resolveAIEdgeHandleStyle({
|
||||
darkMode: false,
|
||||
aiPanelVisible: false,
|
||||
effectiveUiScale: 1,
|
||||
});
|
||||
|
||||
expect(style.color).toBe('rgba(22,32,51,0.82)');
|
||||
expect(style.background).toBe('rgba(15,23,42,0.04)');
|
||||
expect(style.paddingInline).toBe(8);
|
||||
expect(style.borderRadius).toBe('10px 0 0 10px');
|
||||
});
|
||||
});
|
||||
59
frontend/src/utils/aiEntryLayout.ts
Normal file
59
frontend/src/utils/aiEntryLayout.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export const SIDEBAR_UTILITY_ITEM_KEYS = ['tools', 'proxy', 'theme', 'about'] as const;
|
||||
|
||||
export type AIEntryPlacement = 'content-edge';
|
||||
export type AIEdgeHandleAttachment = 'content-shell' | 'panel-shell';
|
||||
|
||||
export interface ResolveAIEdgeHandleStyleInput {
|
||||
darkMode: boolean;
|
||||
aiPanelVisible: boolean;
|
||||
effectiveUiScale: number;
|
||||
}
|
||||
|
||||
export const resolveAIEntryPlacement = (): AIEntryPlacement => 'content-edge';
|
||||
|
||||
export const resolveAIEdgeHandleAttachment = (
|
||||
aiPanelVisible: boolean,
|
||||
): AIEdgeHandleAttachment => (aiPanelVisible ? 'panel-shell' : 'content-shell');
|
||||
|
||||
export const resolveAIEdgeHandleDockStyle = (
|
||||
attachment: AIEdgeHandleAttachment,
|
||||
): CSSProperties => ({
|
||||
position: 'absolute',
|
||||
top: 16,
|
||||
right: attachment === 'panel-shell' ? '100%' : 0,
|
||||
zIndex: 12,
|
||||
});
|
||||
|
||||
export const resolveAIEdgeHandleStyle = ({
|
||||
darkMode,
|
||||
aiPanelVisible,
|
||||
effectiveUiScale,
|
||||
}: ResolveAIEdgeHandleStyleInput): CSSProperties => {
|
||||
const inactiveColor = darkMode ? 'rgba(255,255,255,0.86)' : 'rgba(22,32,51,0.82)';
|
||||
const inactiveBackground = darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(15,23,42,0.04)';
|
||||
const inactiveBorder = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(15,23,42,0.08)';
|
||||
|
||||
return {
|
||||
height: Math.max(24, Math.round(24 * effectiveUiScale)),
|
||||
paddingInline: Math.max(8, Math.round(8 * effectiveUiScale)),
|
||||
borderRadius: '10px 0 0 10px',
|
||||
border: `1px solid ${aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.22)' : 'rgba(24,144,255,0.18)') : inactiveBorder}`,
|
||||
borderRight: 'none',
|
||||
background: aiPanelVisible ? (darkMode ? 'rgba(255,214,102,0.12)' : 'rgba(24,144,255,0.10)') : inactiveBackground,
|
||||
color: aiPanelVisible ? (darkMode ? '#ffd666' : '#1677ff') : inactiveColor,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: Math.max(4, Math.round(4 * effectiveUiScale)),
|
||||
fontSize: Math.max(12, Math.round(12 * effectiveUiScale)),
|
||||
fontWeight: 600,
|
||||
lineHeight: 1,
|
||||
boxShadow: 'none',
|
||||
backdropFilter: 'none',
|
||||
WebkitBackdropFilter: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user