fix(agents): open cards from home and align sidebar navigation
This commit is contained in:
1 parent
e5d79bf37a
commit
5e99804bc2
13 files changed
+460
-27
No files matched your search
@@ -0,0 +1,86 @@
|
||||
# Task: Show Agent cards on entry and align sidebar navigation
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20261012-agent-entry-nav-a13e56c9
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261012-agent-entry-nav-a13e56c9-agent-entry-nav
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-entry-nav-a13e56c9
|
||||
- Base commit: e5d79bf37a35f84a2b08cab0ad4d712b75fca358
|
||||
- Owner: codex
|
||||
- Status: Planning
|
||||
|
||||
## Scope
|
||||
|
||||
- Homepage Agents module entry opens My Agents cards before any individual Agent configuration/conversation.
|
||||
- Align the Agents sidebar return-home arrow and collapse control in one top row, matching the user's reference.
|
||||
- Update the latest local temporary application from this isolated source and preserve its existing profile.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Keep Chinese product language. Current user instruction authorizes both implementation and continued trial update.
|
||||
- Do not clear account-scoped recent/pending recovery data, saved drafts, conversation history, selected model/resources, or backend policies. The card-list intent belongs to explicit entry, not every navigation/mount.
|
||||
- Preserve direct links, explicit card/config/chat actions, draft leave guards, authentication/module access and typed Host APIs.
|
||||
- Scope sidebar presentation to Agents; Code/Canvas/Robot navigation, native titlebar and account interactions stay compatible. Installed application and cloud services are outside scope.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID: 20261012-agent-entry-nav-a13e56c9
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261012-agent-entry-nav-a13e56c9-agent-entry-nav
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261012-agent-entry-nav-a13e56c9
|
||||
- Base commit: e5d79bf37a35f84a2b08cab0ad4d712b75fca358 (verified current temporary application source).
|
||||
- Ownership: bundled start succeeded; status JSON matched task/mode/absolute worktree/branch/base and planning status.
|
||||
- Other active local tasks: 35 peer owners; each task-record Scope / Intent And Constraints / Promotion Candidates read read-only.
|
||||
- Overlap: completed Agent homepage/config/trial tasks overlap but support explicit card-list entry. One child-language task scope remains undefined with no concrete dependency. No blocking semantic conflict; unrelated primary/canonical integration work remains untouched.
|
||||
|
||||
Read:
|
||||
- AGENTS.md; 05-agent-entry/read-before-planning.md and memory-index.md; active task record; planning-gate requirements retained from prior task.
|
||||
- 00-brief/project-positioning.md and success-criteria.md; 20-architecture/community-collaboration.md; 30-worklog/current-state.md integrated snapshot.
|
||||
- 10-decisions/decision-index.md and task-relevant personal-cloud ADR recovery/navigation clauses; 20-architecture/system-overview.md; 40-domain/business-rules.md.
|
||||
- 50-evidence/evidence-index.md; 60-reflection/reflection-index.md; 80-commitments/commitments.md. Canonical content is unchanged from the prior context-loaded task; latest user refinements are authoritative for UI.
|
||||
|
||||
Relevant understanding:
|
||||
- Project goal: approachable AI creation/use for students and creators; client does not introduce courses or own cloud authentication/billing authority.
|
||||
- Current integrated focus: unified module permissions/cloud capabilities and traditional Works login; October personal-Agent UI refinements are newer than integrated guided UI descriptions.
|
||||
- Active task: explicit module entry destination and Agents navigation-row layout, followed by a local trial update.
|
||||
- Constraints/decisions: Main owns credentials/network/recovery, Renderer uses typed APIs; recent and pending records survive restart and account isolation. User explicitly chooses card destinations; opening modules does not call a model.
|
||||
- Evidence/reflections/commitments: local fixture/navigation checks do not establish signed installed-client or paid-provider acceptance. Existing real-release commitments remain separate.
|
||||
- Likely modules: ModuleSelection/CloudAgents routing recovery, Sidebar/TitleBar/MainLayout/ModuleSwitcher, focused unit/layout/Electron tests and README.
|
||||
- Unknown/stale context: integrated old Agent trial/first-use layout is superseded by the current source and user. Native trial clicks have been intermittent; actual UI may need user navigation, and authentication must remain manual after restart.
|
||||
|
||||
Gate result:
|
||||
- Passed.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add explicit card-list entry intent without erasing recovery data or changing individual Agent links.
|
||||
2. Put Agents return and collapse controls on the same row with accessible labels and keep other modules compatible.
|
||||
3. Verify scoped behavior/layout and production build, then update the exact temporary runtime after checking unsaved input.
|
||||
4. Record observed outcome, commit task result, normalize documentation-only runtime metadata and complete ownership.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Homepage Agents and sidebar “我的智能体” now explicitly open the card list via `view=mine`; remembered conversations and pending operations are retained. Individual card actions, direct recovery and channel/shared links keep their existing behavior.
|
||||
- Agents return-home arrow and sidebar toggle share the top navigation row. The return arrow uses the existing orange brand color with an icon-only circular surface; duplicate sidebar return row removed. Expanded, collapsed and preview states remain usable on macOS/Windows; Linux retains its native frame with an application navigation row.
|
||||
- README synchronized. No cloud service, authentication, billing or real model submission changed.
|
||||
- Temporary app replacement prepared under `.trial-runtime/agent-entry-nav-20261012-a13e56c9`; final runtime/UI observation pending.
|
||||
|
||||
## Verification
|
||||
|
||||
- Entry agent: 47 tests passed across new 7-entry checks and existing page/module checks; scoped lint passed.
|
||||
- Root: 51 focused navigation/titlebar/peek/module checks pass after updating one stale “智能体工作区” title expectation to the previously accepted “我的智能体”; typecheck and scoped lint passed.
|
||||
- Production build passed (existing bundle-size/import/Browserslist warnings).
|
||||
- Electron fixture E2E passed: return and fold controls have equal y and separate x, one return button, collapse/expand, dirty-leave protection, homepage re-entry after recent chat lands on cards, and re-opening chat does not submit/publish again.
|
||||
- Task-scoped project-docs drift check passed. Read-only review passed with no actionable findings; 6 Chrome browser layout fixtures passed (Darwin/Win32/Linux × 375/1024): same-row centers, separate 32px hitboxes, collapse/expand and collapsed return; scoped lint passed. Windows 375 is below native desktop minimum, so native chrome separation accepted only at 1024; navigation reachability checked at both widths. Electron card-list screenshot reviewed: return arrow and fold control occupy one horizontal row, with “我的智能体” in the titlebar.
|
||||
- Real old trial observed immediately before replacement through full native AX: saved/applied configuration, conversation input empty, no active running action. No real cloud save/publish/chat action performed.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Native trial replacement and manual login/page observation remain before final completion.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Update current-state/Agents navigation documentation during integration: explicit module homepage entry always opens “我的智能体” cards while recent/pending recovery is retained; return/fold share the top row and remain available when collapsed.
|
||||
- Linux Agents has an application navigation row beneath the native window frame; other Linux modules preserve their prior titlebar behavior.
|
||||
@@ -12,7 +12,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
- `Makelore Code|AI 编程`:管理本地项目、项目智能体、对话、文件上下文、代码变更和运行时。
|
||||
会话工作台采用浅蓝项目栏、彩色通用手柄图标与卡片选中态;中间对话/作品使用圆角标签,作品预览的后退、前进、刷新与网址输入统一放在标签右侧;只在作品页显示,窄窗口自动换行。右侧智能体咨询使用奶油色标题区。沿用真实项目和会话入口、侧栏伸缩与账户菜单,样式只作用于 Code 会话工作台。可编辑的空对话在输入区上方的可用区域中央显示童趣欢迎语;「去作品广场逛逛」入口紧贴输入框上方,两者一起位于底部。老师介绍以固定宽度浮在右上角,不占用中间布局;欢迎语和底部输入区始终相对项目栏右侧的整个内容区域居中;小窗口的欢迎内容独立滚动;发送首条消息后收起欢迎区。侧栏保留「作品广场」入口,只有点击后才在系统浏览器打开公共广场,不请求、摘取或内嵌展示广场作品。打开广场保留当前项目、对话、草稿和附件,不自动发送消息;老师求助按钮保留临时应用的最新文案「帮我整体看🧠」。
|
||||
- `Makelore Agents|AI 智能体`:首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
- `Makelore Agents|AI 智能体`:从模块首页进入时先打开“我的智能体”卡片列表,保留此前对话与操作恢复记录;返回首页箭头与侧栏折叠按钮同排,折叠后仍可返回或展开,macOS 原生窗口按钮留出安全空间。首页围绕自己的智能体和创建入口,显示童趣问候“Hi,今天想创造还是想聊聊?”,不再显示更多、收到的分享、活动或归档筛选。卡片显示名称、可为空的一句话介绍及状态,支持聊天、调整、继续制作和确认删除;删除沿用云端归档机制,移出列表并停用新调用,历史和费用保留。创建先填写名称并选择平台像素头像或上传头像,下一步进入独立配置。顶部名称有编辑图标,头像可点击修改;未设置头像时使用同一张默认像素头像,从创建到配置、改名、保存和重新打开保持一致。基础页为“介绍与提示词”,一句话介绍可留空,完整提示词保留原始文本;模型作为高级设置第一项必选,未选择时不能开始使用,草稿可分步保存,不自动选模型。介绍与提示词采用浅蓝介绍卡和浅紫提示词卡;高级设置按模型、工具箱(Tool)、做事手册(Skill)、MCP 服务、子智能体和知识库纵向呈现,不再设内部分类切换;不展示 Tool/Skill 解释、云端能力管理、费用上限或运行限制卡片,保存其他配置时保留既有运行限制,费用规则不变。顶层页签切换保留搜索、资源选择、知识表单及未保存输入。配置页移除聊天、自动任务和使用与接入导航,独立聊天仍从卡片进入,微信账号仍在侧栏渠道管理。草稿明确保存(Ctrl/⌘+S);保存只更新草稿,“应用最新修改”使新配置生效,失败重试保持原操作身份;点击“开始使用”完成必要的私有启用后直接打开聊天,不再展示二次确认页;未保存修改、缺少模型、冲突或尚未确认的请求会留在配置页处理,打开聊天不自动发送消息。头像上传、Main-owned Host API、访问隔离、费用与不可变版本规则保持;介绍留空需要配套 Yuxi DraftCreate/DraftSave 的空字符串契约。
|
||||
|
||||
云知识库的 embedding 模型只由平台管理员在 Yuxi 后台维护:在「智能体管理 → 模型供应商」启用供应商,按供应商要求填写有效的服务地址和 API Key,添加并启用 embedding 模型并填写实际向量维度;embedding 向量化费用由平台承担,不计入用户用量、词元点数或智能体费用上限;one-api 不参与 embedding 配置,Works Square 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export function MainLayout() {
|
||||
<TitleBar
|
||||
integrated
|
||||
pageTitle={isCloudAgents ? '我的智能体' : undefined}
|
||||
showModuleReturn={isCloudAgents}
|
||||
workspaceLayout={isChatWorkspace}
|
||||
overlay={isPaintingModule}
|
||||
showSidebarControls={!isCanvasWorkspace}
|
||||
|
||||
@@ -12,6 +12,7 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false, toolbar = fa
|
||||
?? `${module.subtitle.replace(/^AI\s*/u, '')} ${module.title.replace(/^Makelore\s+/u, '')}`;
|
||||
const activeModuleLabel = getModuleLabel(activeModule);
|
||||
const compactTrigger = compact && !sidebarCollapsed;
|
||||
const agentReturn = activeModuleId === 'cloud_agents' && toolbar && iconOnly;
|
||||
|
||||
return (
|
||||
<div data-testid="sidebar-module-switcher" className={cn('relative', toolbar && 'min-w-0 flex-1')}>
|
||||
@@ -30,12 +31,13 @@ export function ModuleSwitcher({ sidebarCollapsed, compact = false, toolbar = fa
|
||||
: 'flex min-h-11 w-full justify-between gap-2 rounded-2xl p-3 text-sm',
|
||||
sidebarCollapsed ? 'justify-center px-0' : '',
|
||||
toolbar && iconOnly && 'w-8 justify-center px-0',
|
||||
agentReturn && 'rounded-full bg-background/70 text-brand',
|
||||
)}
|
||||
>
|
||||
<ArrowLeft
|
||||
data-testid="sidebar-module-return-icon"
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 shrink-0 -translate-y-px text-muted-foreground transition-[color,transform] duration-150 group-hover:-translate-x-0.5 group-hover:text-brand"
|
||||
className={cn('h-4 w-4 shrink-0 -translate-y-px transition-[color,transform] duration-150 group-hover:-translate-x-0.5 group-hover:text-brand', agentReturn ? 'text-brand' : 'text-muted-foreground')}
|
||||
/>
|
||||
{!sidebarCollapsed && !iconOnly ? <span className={cn('min-w-0 whitespace-nowrap leading-4', toolbar && 'truncate')}>{activeModuleLabel}</span> : null}
|
||||
</button>
|
||||
|
||||
@@ -404,7 +404,7 @@ export function Sidebar({
|
||||
)}
|
||||
>
|
||||
{workspaceLayout && !sidebarPinnedCollapsed && <ClassroomSidebarResizer />}
|
||||
{!isProjectConfigPage && <div
|
||||
{!isProjectConfigPage && activeModule !== 'cloud_agents' && <div
|
||||
className={cn(
|
||||
'bg-transparent px-3 text-foreground',
|
||||
workspaceLayout && 'classroom-sidebar-header',
|
||||
@@ -502,7 +502,7 @@ export function Sidebar({
|
||||
</>
|
||||
) : (
|
||||
activeModule === 'cloud_agents' ? <div data-testid="sidebar-cloud-agents-navigation" className="px-2 py-3">
|
||||
<button type="button" aria-label="我的智能体" onClick={() => navigate('/cloud-agents')}
|
||||
<button type="button" aria-label="我的智能体" onClick={() => navigate('/cloud-agents?view=mine')}
|
||||
aria-current={location.pathname === '/cloud-agents' ? 'page' : undefined}
|
||||
className={cn('flex min-h-10 w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-sm font-semibold hover:bg-surface-tertiary', location.pathname === '/cloud-agents' && 'bg-brand-soft', sidebarCollapsed && 'justify-center px-0')}>
|
||||
<Bot className="h-4 w-4 shrink-0 text-brand" />
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* macOS: light application toolbar with native traffic lights overlaid by the
|
||||
* full-size hidden title bar.
|
||||
* Windows: drag region with custom minimize/maximize/close controls.
|
||||
* Linux: use native window chrome (no custom title bar).
|
||||
* Linux: retain native window chrome; Agents adds an application navigation row.
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -27,23 +27,25 @@ type TitleBarProps = {
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
showModuleReturn?: boolean;
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: SidebarPeekChange;
|
||||
};
|
||||
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, showSidebarControls = true, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, showSidebarControls = true, pageTitle, showModuleReturn = false, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
const platform = window.electron?.platform;
|
||||
|
||||
if (platform === 'darwin') {
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} showModuleReturn={showModuleReturn} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
// Linux keeps the native frame/title bar for better IME compatibility.
|
||||
// Linux keeps its native window frame. Agents still needs an application
|
||||
// navigation row that remains reachable when the sidebar is collapsed.
|
||||
if (platform !== 'win32') {
|
||||
return null;
|
||||
return showModuleReturn ? <ProductTitleBar integrated={integrated} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} showModuleReturn sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} /> : null;
|
||||
}
|
||||
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} showModuleReturn={showModuleReturn} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
function ProductTitleBar({
|
||||
@@ -52,6 +54,7 @@ function ProductTitleBar({
|
||||
overlay = false,
|
||||
showSidebarControls = true,
|
||||
pageTitle,
|
||||
showModuleReturn = false,
|
||||
children,
|
||||
windowControlsWidth = 0,
|
||||
nativeTrafficLights = false,
|
||||
@@ -63,6 +66,7 @@ function ProductTitleBar({
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
showModuleReturn?: boolean;
|
||||
children?: React.ReactNode;
|
||||
windowControlsWidth?: number;
|
||||
nativeTrafficLights?: boolean;
|
||||
@@ -91,6 +95,8 @@ function ProductTitleBar({
|
||||
// keeps project conversations in the sidebar and must preserve its title rail.
|
||||
const sidebarPreviewTitlebarVisible = sidebarCollapsed && sidebarPeekOpen && !workspaceLayout && !overlay;
|
||||
const sidebarTitlebarHidden = sidebarCollapsed && !sidebarPreviewTitlebarVisible;
|
||||
const agentNavigationWidth = sidebarTitlebarHidden ? (nativeTrafficLights && !nativeFullscreen ? 168 : 88) : 256;
|
||||
const navigationWidth = workspaceLayout ? classroomTitlebarWidth : showModuleReturn ? agentNavigationWidth : undefined;
|
||||
const sidebarToggle = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -101,13 +107,13 @@ function ProductTitleBar({
|
||||
className={cn(
|
||||
toolbarButtonClass,
|
||||
'pointer-events-auto absolute top-1 z-[401]',
|
||||
workspaceLayout ? 'right-2' : nativeTrafficLights ? 'left-[88px]' : 'left-2',
|
||||
workspaceLayout || showModuleReturn ? 'right-2' : nativeTrafficLights ? 'left-[88px]' : 'left-2',
|
||||
)}
|
||||
onClick={toggleSidebar}
|
||||
onPointerEnter={() => handleSidebarPeekEnter('toggle')}
|
||||
onPointerLeave={() => handleSidebarPeekLeave('toggle')}
|
||||
onFocus={!workspaceLayout ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlur={!workspaceLayout ? () => handleSidebarPeekLeave('focus') : undefined}
|
||||
onFocus={!workspaceLayout && !showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlur={!workspaceLayout && !showModuleReturn ? () => handleSidebarPeekLeave('focus') : undefined}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -117,18 +123,18 @@ function ProductTitleBar({
|
||||
data-testid="titlebar-sidebar-toggle-layer"
|
||||
className={cn(
|
||||
'no-drag pointer-events-auto fixed left-0 top-0 z-[400] h-10',
|
||||
!workspaceLayout && (nativeTrafficLights ? 'w-[132px]' : 'w-[52px]'),
|
||||
!workspaceLayout && !showModuleReturn && (nativeTrafficLights ? 'w-[132px]' : 'w-[52px]'),
|
||||
)}
|
||||
style={workspaceLayout ? { width: classroomTitlebarWidth } : undefined}
|
||||
onPointerEnter={workspaceLayout ? () => handleSidebarPeekEnter('titlebar') : undefined}
|
||||
onPointerLeave={workspaceLayout ? () => handleSidebarPeekLeave('titlebar') : undefined}
|
||||
onFocusCapture={workspaceLayout ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlurCapture={workspaceLayout ? (event) => {
|
||||
style={navigationWidth !== undefined ? { width: navigationWidth } : undefined}
|
||||
onPointerEnter={workspaceLayout || showModuleReturn ? () => handleSidebarPeekEnter('titlebar') : undefined}
|
||||
onPointerLeave={workspaceLayout || showModuleReturn ? () => handleSidebarPeekLeave('titlebar') : undefined}
|
||||
onFocusCapture={workspaceLayout || showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlurCapture={workspaceLayout || showModuleReturn ? (event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) handleSidebarPeekLeave('focus');
|
||||
} : undefined}
|
||||
>
|
||||
{workspaceLayout ? <div
|
||||
data-testid="classroom-titlebar-navigation"
|
||||
{workspaceLayout || showModuleReturn ? <div
|
||||
data-testid={workspaceLayout ? 'classroom-titlebar-navigation' : 'agent-titlebar-navigation'}
|
||||
className={cn('flex h-full items-center gap-2 pr-12', nativeTrafficLights && !nativeFullscreen ? 'pl-[88px]' : 'pl-3')}
|
||||
>
|
||||
<ModuleSwitcher sidebarCollapsed={false} toolbar iconOnly />
|
||||
@@ -175,7 +181,7 @@ function ProductTitleBar({
|
||||
<>
|
||||
<div
|
||||
data-testid="titlebar-sidebar-surface"
|
||||
style={workspaceLayout ? { width: classroomTitlebarWidth, minWidth: classroomTitlebarWidth, flexBasis: classroomTitlebarWidth } : undefined}
|
||||
style={navigationWidth !== undefined ? { width: navigationWidth, minWidth: navigationWidth, flexBasis: navigationWidth } : undefined}
|
||||
className={cn(
|
||||
'flex h-full shrink-0 items-center gap-1 px-2',
|
||||
workspaceLayout
|
||||
@@ -251,7 +257,7 @@ function ProductTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarControls, pageTitle, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarControls, pageTitle, showModuleReturn, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -284,6 +290,7 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarCont
|
||||
overlay={overlay}
|
||||
showSidebarControls={showSidebarControls}
|
||||
pageTitle={pageTitle}
|
||||
showModuleReturn={showModuleReturn}
|
||||
windowControlsWidth={WINDOWS_TITLEBAR_CONTROLS_WIDTH}
|
||||
sidebarPeekOpen={sidebarPeekOpen}
|
||||
onSidebarPeekChange={onSidebarPeekChange}
|
||||
|
||||
@@ -83,6 +83,7 @@ function AgentWorkspace() {
|
||||
cloudAgentsApi.recovery().then(async state => {
|
||||
if (!live) return;
|
||||
setPending(state.pending);
|
||||
// Explicit list entries keep the recent workspace pointer without reopening it.
|
||||
if (!state.recent || location.pathname.includes('/shared/') || new URLSearchParams(location.search).has('view')) return;
|
||||
const pointer = state.recent;
|
||||
// Creator drafts may be unpublished; shared entry revalidates current access separately.
|
||||
|
||||
@@ -44,7 +44,7 @@ export function ModuleSelection({ authRequired = true }: { authRequired?: boolea
|
||||
navigate('/login');
|
||||
return;
|
||||
}
|
||||
navigate(route);
|
||||
navigate(route === '/cloud-agents' ? `${route}?view=mine` : route);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { createServer, type ViteDevServer } from 'vite';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
let server: ViteDevServer;
|
||||
let baseUrl: string;
|
||||
test.use({ headless: true, channel: process.env.MAKELORE_LAYOUT_BROWSER_CHANNEL, video: 'off' });
|
||||
|
||||
// Exercise actual navigation controls and persisted layout state without
|
||||
// starting Electron or connecting to an account or any cloud service.
|
||||
test.beforeAll(async () => {
|
||||
const entry = resolve('tests/e2e/__cloud-agent-navigation-layout-harness.tsx');
|
||||
server = await createServer({
|
||||
configFile: false,
|
||||
cacheDir: 'node_modules/.vite-cloud-agent-navigation-layout',
|
||||
resolve: { alias: { '@': resolve('src') } },
|
||||
esbuild: { jsx: 'automatic' },
|
||||
server: { host: '127.0.0.1', port: 0 },
|
||||
plugins: [{
|
||||
name: 'isolated-cloud-agent-navigation-layout', enforce: 'pre',
|
||||
resolveId(id) { if (id === '/__cloud-agent-navigation-layout-harness.tsx') return entry; },
|
||||
load(id) {
|
||||
if (id === entry) return `
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { TitleBar } from '/src/components/layout/TitleBar.tsx';
|
||||
import { useSettingsStore } from '/src/stores/settings.ts';
|
||||
import '/src/styles/globals.css';
|
||||
function AgentFrame() {
|
||||
const collapsed = useSettingsStore(state => state.sidebarCollapsed);
|
||||
const location = useLocation();
|
||||
return <div className="flex h-screen min-w-0 flex-col">
|
||||
<TitleBar integrated showModuleReturn pageTitle="我的智能体" />
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{!collapsed && <aside aria-label="智能体侧栏" className="w-64 shrink-0" />}
|
||||
<main className="min-w-0 flex-1"><output data-testid="fixture-location">{location.pathname}</output></main>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
createRoot(document.getElementById('root')).render(<MemoryRouter initialEntries={['/cloud-agents?view=mine']}>
|
||||
<Routes><Route path="/cloud-agents" element={<AgentFrame />} /><Route path="/module-select" element={<main>首页模块卡片</main>} /></Routes>
|
||||
</MemoryRouter>);
|
||||
`;
|
||||
if (id.endsWith('/src/stores/coding-workspace.ts')) return 'export function useCodingWorkspaceStore(select) { return select({ activeProject: null }); }';
|
||||
if (id.endsWith('/src/stores/user-sync.ts')) return 'export const useUserSyncStore = { getState: () => ({ pushPreferences: async () => undefined }) };';
|
||||
},
|
||||
configureServer(vite) {
|
||||
vite.middlewares.use((req, res, next) => {
|
||||
if (req.url !== '/' && !req.url?.startsWith('/?')) return next();
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.end('<!doctype html><html><head><meta charset="utf-8"></head><body><div id="root"></div><script type="module" src="/__cloud-agent-navigation-layout-harness.tsx"></script></body></html>');
|
||||
});
|
||||
},
|
||||
}],
|
||||
});
|
||||
await server.listen();
|
||||
baseUrl = server.resolvedUrls!.local[0];
|
||||
});
|
||||
test.afterAll(async () => { await server?.close(); });
|
||||
|
||||
async function expectNavigationRow(page: Page, width: number) {
|
||||
const back = page.getByRole('button', { name: '返回首页', exact: true });
|
||||
const toggle = page.getByTestId('titlebar-sidebar-toggle');
|
||||
await expect(back).toHaveCount(1);
|
||||
await expect(back).toBeVisible();
|
||||
await expect(toggle).toBeVisible();
|
||||
const a = (await back.boundingBox())!;
|
||||
const b = (await toggle.boundingBox())!;
|
||||
expect(Math.abs((a.y + a.height / 2) - (b.y + b.height / 2))).toBeLessThanOrEqual(1);
|
||||
expect(a.x + a.width).toBeLessThanOrEqual(b.x);
|
||||
for (const box of [a, b]) {
|
||||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||||
expect(box.x + box.width).toBeLessThanOrEqual(width);
|
||||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||||
expect(box.width).toBeGreaterThanOrEqual(32);
|
||||
expect(box.height).toBeGreaterThanOrEqual(32);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
for (const platform of ['darwin', 'win32', 'linux']) {
|
||||
for (const width of [375, 1024]) {
|
||||
test(`${platform} Agent return and sidebar toggle share one usable row at ${width}px`, async ({ page }) => {
|
||||
const pageErrors: string[] = [];
|
||||
page.on('pageerror', error => pageErrors.push(error.message));
|
||||
await page.setViewportSize({ width, height: 760 });
|
||||
await page.addInitScript(platform => {
|
||||
Object.defineProperty(window, 'electron', { value: { platform, ipcRenderer: {
|
||||
invoke: async (channel: string) => {
|
||||
if (channel === 'window:isMaximized' || channel === 'window:isFullScreen') return false;
|
||||
throw new Error('Unexpected fixture IPC ' + channel);
|
||||
},
|
||||
on: () => () => {},
|
||||
} } });
|
||||
}, platform);
|
||||
await page.goto(baseUrl);
|
||||
await expect(page.getByTestId('fixture-location')).toHaveText('/cloud-agents');
|
||||
const expandedToggle = await expectNavigationRow(page, width);
|
||||
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveAttribute('aria-label', '折叠侧栏');
|
||||
|
||||
// 375px stresses application controls; Windows' native desktop frame has
|
||||
// a larger minimum width, so validate its chrome separation at 1024px.
|
||||
if (platform !== 'win32' || width === 1024) {
|
||||
const logo = (await page.getByTestId('titlebar-logo').boundingBox())!;
|
||||
expect(expandedToggle.x + expandedToggle.width).toBeLessThanOrEqual(logo.x);
|
||||
}
|
||||
await page.getByTestId('titlebar-sidebar-toggle').click();
|
||||
await expect(page.getByRole('complementary', { name: '智能体侧栏' })).toHaveCount(0);
|
||||
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveAttribute('aria-label', '展开侧栏');
|
||||
const collapsedToggle = await expectNavigationRow(page, width);
|
||||
const collapsedLogo = (await page.getByTestId('titlebar-logo').boundingBox())!;
|
||||
expect(collapsedToggle.x + collapsedToggle.width).toBeLessThanOrEqual(collapsedLogo.x);
|
||||
|
||||
await page.getByTestId('titlebar-sidebar-toggle').click();
|
||||
await expect(page.getByRole('complementary', { name: '智能体侧栏' })).toBeVisible();
|
||||
await expectNavigationRow(page, width);
|
||||
await page.getByTestId('titlebar-sidebar-toggle').click();
|
||||
await page.getByRole('button', { name: '返回首页', exact: true }).click();
|
||||
await expect(page.getByText('首页模块卡片')).toBeVisible();
|
||||
expect(pageErrors).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,20 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-robot-navigation')).toHaveCount(0);
|
||||
const navigation = page.getByTestId('agent-titlebar-navigation');
|
||||
await expect(navigation.getByRole('button', { name: '返回首页', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '返回首页', exact: true })).toHaveCount(1);
|
||||
const returnBounds = await navigation.getByRole('button', { name: '返回首页', exact: true }).boundingBox();
|
||||
const collapseBounds = await navigation.getByRole('button', { name: '折叠侧栏', exact: true }).boundingBox();
|
||||
expect(returnBounds).not.toBeNull();
|
||||
expect(collapseBounds).not.toBeNull();
|
||||
expect(Math.abs(returnBounds!.y - collapseBounds!.y)).toBeLessThanOrEqual(1);
|
||||
expect(returnBounds!.x + returnBounds!.width).toBeLessThan(collapseBounds!.x);
|
||||
await navigation.getByRole('button', { name: '折叠侧栏', exact: true }).click();
|
||||
await page.mouse.move(700, 400);
|
||||
await expect(navigation.getByRole('button', { name: '返回首页', exact: true })).toBeVisible();
|
||||
await navigation.getByRole('button', { name: /展开侧栏|固定侧栏/ }).click();
|
||||
await expect(page.getByTestId('sidebar-cloud-agents-navigation')).toBeVisible();
|
||||
const fixtureState = () => app.evaluate(() => Reflect.get(globalThis, '__cloudAgentUiFixture').snapshot());
|
||||
const messageOperations = async () => (await fixtureState()).operations.filter((operation: string) => operation === 'preview' || operation === 'submit');
|
||||
await page.getByRole('button', { name: '创建我的第一个智能体', exact: true }).click();
|
||||
@@ -249,6 +263,18 @@ test('personal cloud Agent identity, configuration and separate published chat i
|
||||
await expect(page.getByRole('button', { name: /^(聊天|自动任务|使用与接入)$/ })).toHaveCount(0);
|
||||
expect(await messageOperations()).toEqual(['submit']);
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-separate-chat.png') });
|
||||
const operationsBeforeReentry = (await fixtureState()).operations;
|
||||
await page.getByRole('button', { name: '返回首页', exact: true }).click();
|
||||
await page.getByTestId('ai-module-option-cloud_agents').click();
|
||||
await expect(page.getByRole('article', { name: '我的新版写作搭档', exact: true })).toBeVisible();
|
||||
await expect(editor).toHaveCount(0);
|
||||
await expect(formalChat).toHaveCount(0);
|
||||
await page.screenshot({ path: testInfo.outputPath('cloud-agent-home-entry-navigation.png') });
|
||||
await page.getByRole('article', { name: '我的新版写作搭档', exact: true }).getByRole('button', { name: '和它聊聊', exact: true }).click();
|
||||
await expect(formalChat).toBeVisible();
|
||||
expect(await messageOperations()).toEqual(['submit']);
|
||||
const operationsAfterReentry = (await fixtureState()).operations;
|
||||
expect(operationsAfterReentry.filter((operation: string) => operation === 'publish')).toEqual(operationsBeforeReentry.filter((operation: string) => operation === 'publish'));
|
||||
} finally {
|
||||
await app.evaluate(({ BrowserWindow }) => { for (const win of BrowserWindow.getAllWindows()) win.destroy(); }).catch(() => undefined);
|
||||
await closeElectronApp(app);
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router-dom';
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { ModuleSelection } from '@/pages/ModuleSelection';
|
||||
import CloudAgents from '@/pages/CloudAgents';
|
||||
import { EMPTY_CLOUD_CONFIGURATION, type CloudAgentDraft, type CloudRecent } from '../../shared/cloud-agents';
|
||||
|
||||
const api = vi.hoisted(() => ({ list: vi.fn(), create: vi.fn(), uploadAvatar: vi.fn(), get: vi.fn(), save: vi.fn(), call: vi.fn(), events: vi.fn(), recovery: vi.fn(), remember: vi.fn(), resolvePending: vi.fn() }));
|
||||
const auth = vi.hoisted(() => ({
|
||||
user: { userId: 'creator', username: 'creator' },
|
||||
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
|
||||
isAuthenticated: () => true,
|
||||
}));
|
||||
vi.mock('@/lib/cloud-agents-api', () => ({ cloudAgentsApi: api }));
|
||||
vi.mock('@/stores/auth', () => ({ useAuthStore: (selector: (state: typeof auth) => unknown) => selector(auth) }));
|
||||
vi.mock('@/hooks/use-current-user-profile', () => ({ useCurrentUserProfile: () => ({ userProfile: undefined, profileRequired: false, profileSyncError: null }) }));
|
||||
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
|
||||
vi.mock('@/pages/CloudAgents/DraftEditor', () => ({
|
||||
DraftEditor: ({ initial, entryView, recent }: { initial: CloudAgentDraft; entryView?: string; recent: CloudRecent | null }) =>
|
||||
<div data-testid="entry-editor" data-entry-view={entryView ?? 'restored'} data-thread={recent?.thread_id}>{initial.name}</div>,
|
||||
}));
|
||||
vi.mock('@/pages/CloudAgents/CloudChat', () => ({ CloudChat: ({ slug }: { slug: string }) => <div data-testid="entry-chat">{slug}</div> }));
|
||||
vi.mock('@/pages/CloudAgents/ChannelAccounts', () => ({ ChannelAccounts: () => <div>微信渠道页面</div> }));
|
||||
|
||||
const draft: CloudAgentDraft = {
|
||||
slug: 'ml-' + 'a'.repeat(32), name: '学习朋友', purpose: '一起探索新知识', system_prompt: '回答问题',
|
||||
draft_revision: 4, updated_at: '2026-10-12T00:00:00Z', configuration: EMPTY_CLOUD_CONFIGURATION,
|
||||
published_version: 2, enabled: true,
|
||||
};
|
||||
const recent: CloudRecent = { slug: draft.slug, thread_id: 'recent-thread', mode: 'published' };
|
||||
const pending = { id: 'pending-message', operation: 'submit', input: { query: '上次的问题' }, created_at: '2026-10-12T00:00:00Z' };
|
||||
|
||||
function open(entry = '/module-select') {
|
||||
const router = createMemoryRouter([
|
||||
{ path: '/module-select', element: <ModuleSelection /> },
|
||||
{ path: '/cloud-agents/*', element: <CloudAgents /> },
|
||||
], { initialEntries: [entry] });
|
||||
render(<RouterProvider router={router} />);
|
||||
return router;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
api.list.mockResolvedValue({ agents: [draft], next_cursor: null });
|
||||
api.get.mockResolvedValue(draft);
|
||||
api.recovery.mockResolvedValue({ recent, pending: [pending] });
|
||||
api.remember.mockResolvedValue({ saved: true });
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
it('opens the card list from the home Agents choice without changing the remembered workspace', async () => {
|
||||
const router = open();
|
||||
fireEvent.click(screen.getByTestId('ai-module-option-cloud_agents'));
|
||||
|
||||
expect(await screen.findByRole('article', { name: draft.name })).toBeVisible();
|
||||
expect(await screen.findByRole('complementary', { name: '未确认的云端操作' })).toHaveTextContent('上次的问题');
|
||||
expect(router.state.location.pathname + router.state.location.search).toBe('/cloud-agents?view=mine');
|
||||
expect(screen.queryByTestId('entry-editor')).not.toBeInTheDocument();
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
expect(api.remember).not.toHaveBeenCalled();
|
||||
expect(api.resolvePending).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens an available Agent conversation only after its card is selected', async () => {
|
||||
open();
|
||||
fireEvent.click(screen.getByTestId('ai-module-option-cloud_agents'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '和它聊聊', exact: true }));
|
||||
|
||||
expect(await screen.findByTestId('entry-editor')).toHaveAttribute('data-entry-view', 'chat');
|
||||
expect(screen.getByTestId('entry-editor')).not.toHaveAttribute('data-thread');
|
||||
expect(api.call).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not replace an explicitly opened draft when recovery arrives late', async () => {
|
||||
let resolveRecovery!: (value: { recent: CloudRecent; pending: typeof pending[] }) => void;
|
||||
api.recovery.mockReturnValue(new Promise(resolve => { resolveRecovery = resolve; }));
|
||||
api.list.mockResolvedValue({ agents: [{ ...draft, published_version: null }], next_cursor: null });
|
||||
open();
|
||||
fireEvent.click(screen.getByTestId('ai-module-option-cloud_agents'));
|
||||
fireEvent.click(await screen.findByRole('button', { name: '继续制作', exact: true }));
|
||||
expect(screen.getByTestId('entry-editor')).toHaveAttribute('data-entry-view', 'configuration');
|
||||
|
||||
await act(async () => resolveRecovery({ recent: { ...recent, slug: 'ml-' + 'b'.repeat(32) }, pending: [pending] }));
|
||||
expect(screen.getByTestId('entry-editor')).toHaveTextContent(draft.name);
|
||||
expect(screen.getByTestId('entry-editor')).toHaveAttribute('data-entry-view', 'configuration');
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
expect(api.remember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves remembered conversation recovery for a direct workspace entry', async () => {
|
||||
open('/cloud-agents');
|
||||
expect(await screen.findByTestId('entry-editor')).toHaveAttribute('data-thread', 'recent-thread');
|
||||
expect(api.get).toHaveBeenCalledWith(draft.slug);
|
||||
expect(api.remember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns from a remembered workspace to the explicit card list without erasing history', async () => {
|
||||
const router = open('/cloud-agents');
|
||||
await screen.findByTestId('entry-editor');
|
||||
api.get.mockClear();
|
||||
await act(async () => { await router.navigate('/cloud-agents?view=mine'); });
|
||||
|
||||
expect(await screen.findByRole('article', { name: draft.name })).toBeVisible();
|
||||
await waitFor(() => expect(api.recovery).toHaveBeenCalledTimes(2));
|
||||
expect(screen.queryByTestId('entry-editor')).not.toBeInTheDocument();
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
expect(api.remember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves explicit shared Agent links instead of reopening the remembered owner draft', async () => {
|
||||
const sharedSlug = 'ml-' + 'b'.repeat(32);
|
||||
api.call.mockResolvedValue({ slug: sharedSlug, name: '分享朋友', purpose: '', available: true });
|
||||
open(`/cloud-agents/shared/${sharedSlug}`);
|
||||
expect(await screen.findByTestId('entry-chat')).toHaveTextContent(sharedSlug);
|
||||
expect(api.call).toHaveBeenCalledWith('entry', { slug: sharedSlug });
|
||||
expect(api.get).not.toHaveBeenCalled();
|
||||
expect(api.remember).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves explicit channel links', async () => {
|
||||
open('/cloud-agents/channels');
|
||||
expect(screen.getByText('微信渠道页面')).toBeVisible();
|
||||
expect(api.recovery).not.toHaveBeenCalled();
|
||||
expect(api.list).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -23,11 +23,12 @@ vi.mock('@/components/layout/Sidebar', () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/layout/TitleBar', () => ({
|
||||
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, sidebarPeekOpen = false, onSidebarPeekChange }: {
|
||||
TitleBar: ({ integrated = false, overlay = false, showSidebarControls = true, pageTitle, showModuleReturn = false, sidebarPeekOpen = false, onSidebarPeekChange }: {
|
||||
integrated?: boolean;
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
showModuleReturn?: boolean;
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
|
||||
}) => (
|
||||
@@ -37,6 +38,7 @@ vi.mock('@/components/layout/TitleBar', () => ({
|
||||
data-overlay={overlay ? 'true' : 'false'}
|
||||
data-sidebar-controls={showSidebarControls ? 'true' : 'false'}
|
||||
data-page-title={pageTitle ?? ''}
|
||||
data-module-return={String(showModuleReturn)}
|
||||
data-peek-open={String(sidebarPeekOpen)}
|
||||
data-has-peek-handler={String(Boolean(onSidebarPeekChange))}
|
||||
/>
|
||||
@@ -93,8 +95,9 @@ describe('MainLayout module isolation', () => {
|
||||
renderLayout('/cloud-agents');
|
||||
expect(screen.getByTestId('main-content')).toHaveClass('h-full', 'overflow-hidden', 'p-0');
|
||||
expect(screen.getByTestId('main-content')).not.toHaveClass('p-5', 'sm:p-6');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-page-title', '智能体工作区');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-page-title', '我的智能体');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-module-return', 'true');
|
||||
expect(screen.getByTestId('sidebar-stub')).toHaveAttribute('data-hide-on-compact', 'false');
|
||||
});
|
||||
|
||||
@@ -110,6 +113,7 @@ describe('MainLayout module isolation', () => {
|
||||
|
||||
expect(screen.getByTestId('route-content')).toBeVisible();
|
||||
expect(screen.queryByTestId('project-initialization-gate')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-module-return', 'false');
|
||||
expect(screen.getByTestId('titlebar-stub')).toHaveAttribute('data-overlay', 'false');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { ReactElement } from 'react';
|
||||
import { act, fireEvent, render as renderComponent, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render as renderComponent, screen, waitFor, within } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { TitleBar } from '@/components/layout/TitleBar';
|
||||
import { CodingConversationHeader } from '@/pages/Chat/CodingConversationHeader';
|
||||
@@ -28,6 +28,64 @@ describe('TitleBar platform behavior', () => {
|
||||
useSettingsStore.setState({ sidebarCollapsed: false, classroomSidebarWidth: 171 });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['darwin', 168, 'pl-[88px]'],
|
||||
['win32', 88, 'pl-3'],
|
||||
['linux', 88, 'pl-3'],
|
||||
] as const)('keeps Agents return and collapse together and reachable on %s', (platform, collapsedWidth, inset) => {
|
||||
window.electron.platform = platform;
|
||||
renderComponent(<MemoryRouter initialEntries={['/cloud-agents?view=mine']}>
|
||||
<TitleBar integrated showModuleReturn pageTitle="我的智能体" />
|
||||
<LocationPath />
|
||||
</MemoryRouter>);
|
||||
const navigation = screen.getByTestId('agent-titlebar-navigation');
|
||||
const back = within(navigation).getByRole('button', { name: '返回首页' });
|
||||
const toggle = within(navigation).getByRole('button', { name: '折叠侧栏' });
|
||||
expect(navigation).toHaveClass('flex', 'items-center', inset);
|
||||
expect(back).toHaveClass('text-brand', 'rounded-full');
|
||||
expect(back).not.toHaveTextContent('Agents');
|
||||
expect(screen.getAllByRole('button', { name: '返回首页' })).toHaveLength(1);
|
||||
expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: '256px' });
|
||||
expect(toggle).toHaveClass('right-2');
|
||||
fireEvent.click(toggle);
|
||||
expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: `${collapsedWidth}px` });
|
||||
expect(back).toBeVisible();
|
||||
fireEvent.click(within(navigation).getByRole('button', { name: '展开侧栏' }));
|
||||
expect(useSettingsStore.getState().sidebarCollapsed).toBe(false);
|
||||
fireEvent.click(back);
|
||||
expect(screen.getByTestId('location-path')).toHaveTextContent('/module-select');
|
||||
});
|
||||
|
||||
it('uses the compact Agents navigation inset in macOS fullscreen', async () => {
|
||||
window.electron.platform = 'darwin';
|
||||
invokeIpcMock.mockResolvedValue(true);
|
||||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||||
renderComponent(<MemoryRouter initialEntries={['/cloud-agents']}>
|
||||
<TitleBar integrated showModuleReturn />
|
||||
</MemoryRouter>);
|
||||
await waitFor(() => expect(screen.getByTestId('agent-titlebar-navigation')).toHaveClass('pl-3'));
|
||||
expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: '88px' });
|
||||
});
|
||||
|
||||
it('keeps Agents keyboard preview open between return and expand controls', () => {
|
||||
window.electron.platform = 'darwin';
|
||||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||||
const onPeek = vi.fn();
|
||||
renderComponent(<MemoryRouter initialEntries={['/cloud-agents']}>
|
||||
<TitleBar integrated showModuleReturn sidebarPeekOpen onSidebarPeekChange={onPeek} />
|
||||
</MemoryRouter>);
|
||||
const back = screen.getByRole('button', { name: '返回首页' });
|
||||
const toggle = screen.getByRole('button', { name: '固定侧栏' });
|
||||
fireEvent.focus(back);
|
||||
expect(onPeek).toHaveBeenLastCalledWith(true, 'focus');
|
||||
onPeek.mockClear();
|
||||
fireEvent.blur(back, { relatedTarget: toggle });
|
||||
expect(onPeek).not.toHaveBeenCalled();
|
||||
fireEvent.blur(toggle, { relatedTarget: null });
|
||||
expect(onPeek).toHaveBeenLastCalledWith(false, 'focus');
|
||||
expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: '256px' });
|
||||
});
|
||||
|
||||
it('extends the product surfaces through the macOS title bar', () => {
|
||||
window.electron.platform = 'darwin';
|
||||
|
||||
|
||||
Reference in new issue
Block a user