fix(agents): open cards from home and align sidebar navigation

This commit is contained in:
鲨鱼辣椒 committed 2026-10-12 20:06:22 +08:00
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.
+1 -1
View File
@@ -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 只负责其他应计费模型调用的创建者词元点数账本。客户端区分目录加载中、加载失败与无可用模型,空状态提供管理员配置说明;「刷新知识库与模型」保留未提交名称,未确认的创建请求沿用原操作身份。
+1
View File
@@ -79,6 +79,7 @@ export function MainLayout() {
<TitleBar
integrated
pageTitle={isCloudAgents ? '我的智能体' : undefined}
showModuleReturn={isCloudAgents}
workspaceLayout={isChatWorkspace}
overlay={isPaintingModule}
showSidebarControls={!isCanvasWorkspace}
+3 -1
View File
@@ -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>
+2 -2
View File
@@ -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" />
+26 -19
View File
@@ -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}
+1
View File
@@ -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.
+1 -1
View File
@@ -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([]);
});
}
}
+26
View File
@@ -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();
});
+6 -2
View File
@@ -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');
});
+59 -1
View File
@@ -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';