Merge simplified Code sidebar and shared configuration navigation
This commit is contained in:
commit
9c693d551f
10 files changed
+264
-76
No files matched your search
@@ -24,7 +24,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
|
||||
Agents 侧栏的「渠道」按每个微信一张卡展示备注、当前智能体与使用状态;一个微信同时只绑定一个智能体,多个微信可以使用同一智能体。常用操作是“更换智能体”,在同一个选择窗口中确认;暂停自动回复和解除微信绑定放在“更多操作”。暂停停止当前任务并保留登录、绑定和历史;更换保留登录与原启停状态;解除微信绑定是完整操作,停止任务、清除目标并退出微信登录,历史保留,再使用需扫码。掉线显示重新扫码入口,暂停显示继续回复,未知或未同步状态显示正在确认,不宣称可用。添加流程为选择已开始使用的智能体、扫码、自动完成绑定;只有这次用户发起的绑定在扫码确认、目标正确、启用成功且最终状态回读可用后才显示完成,不因页面打开、旧记录或验证码提交自动启用。无可用智能体时说明创建和“开始使用”的前提,加载失败不冒充空态,已有微信仍可管理或解绑。Main 持有凭据与原操作恢复;解除需要配套 Yuxi 用户级 `/channel-accounts/{id}/unbind`,旧后端不会通过改按钮文案或组合未确认操作假装完成。变更后以最新读取状态为准,未完成操作保留原身份供显式恢复;失败在当前窗口可见,结果未知不自动重放。服务端显示微信退出仍在处理中时,禁止新的连接、启停和绑定变更,保留刷新与原操作恢复;管理写入前再次读取当前状态。客户端已知目标智能体停用时明确显示不可用,并禁止恢复自动回复,仍可更换目标或安全暂停、解绑。旧请求无法安全恢复、账号关系不一致或目标不可用时提示先核对,保留原记录并避免盲目重试;账号关系或目标智能体问题可显式刷新状态后再次确认原操作,刷新本身不重放操作;“不再提醒”只移除本机提醒,不取消服务端仍在处理的操作。最新 Yuxi 的退出保护条件和持久恢复由服务端负责,发布需配套 Core/Adapter 与 Yuxi business=13、knowledge=2 数据库版本。渠道页不展示微信聊天或活动,既有历史、费用、内容隔离与应用 API 保持原边界。实际微信身份及重复扫码识别仍由云端负责,不从备注推测。
|
||||
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏沿用 Agents 的图标徽章与文字入口,新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Canvas|AI 绘画`:每个设计项目(Workspace)维护一份从创建起就存在的 Living Form。左侧项目栏沿用 Agents 的图标徽章与文字入口,分组标题为“我的项目”,桌面项目列表仅显示名称,隐藏名称下的设计版本与更新时间;新建、切换和项目操作菜单集中管理 Workspace。工作区统一项目标题、设计版本与制作状态,创作对话和作品区支持拖动分栏、键盘调宽及单栏展开;对话以 Markdown 呈现,底部输入器保留草稿、发送状态与 Enter / Shift+Enter 快捷键。右侧通过“当前作品 / 历史作品”切换,在大图或视频预览旁编辑唯一制作方案、确认费用和制作;历史作品可回到工作区查看,切换面板保留未保存的制作参数。紧凑窗口以“创作对话 / 作品与方案”切换面板,通过项目抽屉管理 Workspace。图片支持适应窗口、原始尺寸及 Esc 关闭,视频支持预览和保存到电脑。参考图从本地上传后以 `@图片N` 绑定,具体用法只写在创作提示词中。
|
||||
- `Makelore Robot|AI 机器`:管理机器人智能体、设备激活绑定、智能体配置与设备分配;机器人工作台的智能体位于 Robot 全局侧栏,选中后在内容区先查看绑定设备、再查看基础设置,当前智能体通过 URL 参数保持可分享选择;绑定设备时默认先选择“引导配网”或“已有激活码”。在 Windows 与 macOS 的引导路径中,Makelore 可在弹窗内扫描并连接附近开放的 `Xiaozhi-*` 配网热点,失败时仍可通过系统 Wi-Fi 手动连接;后续继续复用机器人现有热点配网页面,不修改固件,也不由 Makelore 接收 Wi-Fi 密码。
|
||||
|
||||
应用启动默认进入 AI 模块入口选择页。入口页可在未登录状态浏览;未登录用户点击已开通模块时进入客户端原生登录页,可使用账号密码或手机号短信验证码登录。密码登录可选“记住密码”:正式安装包仅由 Electron Main 使用系统受保护凭据存储加密保存和回填账号密码,不写入 Renderer 持久状态,未打包开发版或系统安全存储不可用时禁用该选项。登录请求由 Renderer 经 Host API 交给 Electron Main,再由 Main 调用 Works Square;成功后回到入口选择页。已登录时,Electron Main 会从 Works Square `/api/auth/me` 读取当前账号,只向 Renderer 投影用户名、账号/租户/部门标识、权限名列表与四个模块布尔开关,不透传上游资料或凭据。工作区门禁同时要求有效 Token 和完整用户身份;旧状态缺失身份时会先尝试从 Main 恢复,仍无法确认则清除残留会话并返回登录页。被管理员关闭的模块会在入口页置灰且无法点击,直接访问其工作区路径也会返回入口页。旧服务端未返回策略或缺少单项字段时默认开放;这个客户端门禁不替代服务端 API 授权。
|
||||
@@ -41,7 +41,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
|
||||
- macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。
|
||||
- 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。
|
||||
- AI 编程核心对话运行时:Electron Main 按需启动一个长驻 Pi Agent Server,同一进程内为每条 Conversation 建立独立的 Runtime、Session、凭证视图、扩展上下文和 JSONL 通道,同时复用 Pi 模块与进程启动成本;Server 异常退出时所有旧通道统一失效,下一次恢复只重启一个 Server。正式包中的 Agent Server 从明确传入的 `resources/pi-runtime` 根定位依赖清单与包入口,不从脚本所在目录或应用 `node_modules` 猜测解析。Renderer 不直接启动或调用 Pi,也不读取其 wire 类型。项目与 Conversation 元数据先从本地读取,选中对话后才按需准备对应逻辑线程;输入框不等待运行时准备完成。同一助手工具批次中,会取得项目写租约的 `bash`、`edit`、`write` 与动态产品工具按顺序执行,避免批次在命令启动前自锁;纯只读工具仍可并行。子 Agent 仍使用独立的短生命周期进程,以保留并发配额、故障和写入边界;macOS 上两类后台进程都使用 Electron 自带的 Helper,不在 Dock 中创建额外应用图标。
|
||||
- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。
|
||||
- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。左侧不再展示麦洛品牌与说明语,“新建项目”置于原品牌区,“我的项目”与列表上移到原新建项目区域;“作品广场”与“新建项目”沿用 Agent 的彩色图标徽章+文字样式,不再使用整行白色块与厚阴影,其中“新建项目”位于品牌下方原广场入口的位置,“作品广场”移至底部、紧邻账户按钮上方;“我的项目”和项目列表紧接新建项目,列表沿用图标与文字布局及浅色选中反馈;进入“会话编程设置”后的项目配置页及插件资源页复用相同的左侧工作栏,返回与收起按钮、项目和会话列表、底部作品广场与账户的位置保持一致,并沿用当前宽度和收起状态;配置正文、顶部项目名称与配置页自身的返回操作保留。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。
|
||||
- AI 编程历史与配额反馈:Pi 上下文压缩不删除用户可见的 active-branch 历史,压缩摘要保持 Main-private;Renderer 首次渲染最近 120 个节点,向上滚动时按 100 个节点追加更早内容并保持阅读位置。Main 精确识别 `token_point_balance_exhausted` / `词元点数余额不足`,只向 Renderer 投影不可重试的稳定配额错误和安全中文提示,重开会话后仍可见且不暴露上游原始错误或 request id。
|
||||
- 账号词元点数:左下角账户菜单显示本人永久余额和预占;“词元点数与充值”入口暂时隐藏,充值、充值订单与分页收支记录的既有实现保留。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长为孩子充值时登录孩子的账号,页面显示当前昵称和账号,点数进入该账号自己的钱包;无需将孩子身份改为家长。当前有效账号可给本人充值,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理,充值不会自动切换共享/个人来源。需配套部署 Works Square 当前账号充值资格更新(本次无需新迁移,原永久点数基础为 0094);本地模拟验收不代表真实支付通过。
|
||||
- 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。
|
||||
|
||||
@@ -34,17 +34,6 @@ type ProjectDialogState =
|
||||
| { mode: 'create'; project: null }
|
||||
| { mode: 'rename'; project: DesignWorkspaceSummary };
|
||||
|
||||
function formatUpdatedAt(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return '';
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) {
|
||||
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
return date.toLocaleDateString([], { month: 'numeric', day: 'numeric' });
|
||||
}
|
||||
|
||||
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
|
||||
const authenticated = useAuthStore((state) => state.isAuthenticated());
|
||||
const navigate = useNavigate();
|
||||
@@ -178,7 +167,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
</div>
|
||||
|
||||
<div className="mb-1 mt-5 flex min-h-10 items-center justify-between px-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground">设计项目</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">我的项目</span>
|
||||
{(status === 'error' || status === 'unavailable') && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,9 +240,6 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
|
||||
<span className="block truncate text-sm font-medium text-foreground">
|
||||
{project.title}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-xs text-muted-foreground">
|
||||
设计版本 {project.specificationRevision} · {formatUpdatedAt(project.updatedAt)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2">
|
||||
|
||||
@@ -12,6 +12,7 @@ import { getAiModuleForPath } from '@/lib/ai-modules';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { SidebarPeekSource } from './sidebar-peek';
|
||||
import '@/pages/Chat/classroom-workspace.css';
|
||||
|
||||
const SIDEBAR_PEEK_CLOSE_DELAY_MS = 180;
|
||||
|
||||
@@ -31,6 +32,7 @@ export function MainLayout() {
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isCanvasWorkspace = location.pathname === '/image-canvas';
|
||||
const isChatWorkspace = location.pathname === '/chat';
|
||||
const isCodeSidebar = isChatWorkspace || location.pathname === '/project-config' || location.pathname.startsWith('/project-config/');
|
||||
const isCloudAgents = activeModule === 'cloud_agents';
|
||||
|
||||
const handleSidebarPeekChange = useCallback((open: boolean, source: SidebarPeekSource) => {
|
||||
@@ -80,13 +82,14 @@ export function MainLayout() {
|
||||
if (location.pathname === '/settings' || location.pathname.startsWith('/settings/')) return <SettingsLayout />;
|
||||
|
||||
return (
|
||||
<div data-testid="main-layout" data-classroom={isChatWorkspace} className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
|
||||
<div data-testid="main-layout" data-classroom={isChatWorkspace} data-code-sidebar={isCodeSidebar} className="relative flex h-[100dvh] flex-col overflow-hidden bg-transparent font-sans">
|
||||
{/* Title bar: drag region on macOS, icon + controls on Windows */}
|
||||
<TitleBar
|
||||
integrated
|
||||
pageTitle={isCloudAgents ? '我的智能体' : undefined}
|
||||
showModuleReturn={isCloudAgents}
|
||||
workspaceLayout={isChatWorkspace}
|
||||
workspaceSidebar={isCodeSidebar}
|
||||
overlay={isPaintingModule}
|
||||
showSidebarControls={!isCanvasWorkspace}
|
||||
sidebarPeekOpen={isCanvasWorkspace || isCloudAgents ? false : sidebarPeekOpen}
|
||||
@@ -96,7 +99,7 @@ export function MainLayout() {
|
||||
{/* Below the title bar: sidebar + content */}
|
||||
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||
<Sidebar
|
||||
workspaceLayout={isChatWorkspace}
|
||||
workspaceLayout={isCodeSidebar}
|
||||
hideOnCompact={isCanvasWorkspace}
|
||||
forceExpanded={isCanvasWorkspace}
|
||||
sidebarPeekOpen={isCanvasWorkspace || isCloudAgents ? false : sidebarPeekOpen}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import classroomMark from '@/assets/consultations/mark.svg';
|
||||
import { CodingProjectConversations } from '@/pages/Chat/CodingProjectConversations';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
@@ -141,7 +140,6 @@ export function Sidebar({
|
||||
const isProgrammingModule = activeModule === 'programming';
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
const isRobotModule = activeModule === 'robot';
|
||||
const isProjectConfigPage = location.pathname === '/project-config' || location.pathname.startsWith('/project-config/');
|
||||
const visibleProjects = projects;
|
||||
const selectedProjectFolderName = getFolderName(newProjectSelectedPath);
|
||||
const accountName = userProfile?.displayName?.trim() || getAuthUserDisplayName(authUser) || '未登录用户';
|
||||
@@ -431,7 +429,7 @@ export function Sidebar({
|
||||
)}
|
||||
>
|
||||
{workspaceLayout && !sidebarPinnedCollapsed && <ClassroomSidebarResizer />}
|
||||
{!isProjectConfigPage && activeModule !== 'cloud_agents' && <div
|
||||
{activeModule !== 'cloud_agents' && <div
|
||||
className={cn(
|
||||
'bg-transparent px-3 text-foreground',
|
||||
workspaceLayout && 'classroom-sidebar-header',
|
||||
@@ -443,10 +441,6 @@ export function Sidebar({
|
||||
{workspaceLayout && !['darwin', 'win32'].includes(window.electron?.platform ?? '') && <div className="no-drag flex h-10 min-w-0 items-center gap-1" data-testid="classroom-sidebar-navigation">
|
||||
<ModuleSwitcher sidebarCollapsed={sidebarCollapsed} toolbar iconOnly />
|
||||
</div>}
|
||||
{workspaceLayout && <button type="button" aria-label="返回首页" title="返回首页" onClick={() => navigate('/module-select')} className="classroom-brand flex w-full items-center gap-2 rounded-lg text-left">
|
||||
<span className="classroom-brand-mark"><img src={classroomMark} alt="" className="h-7 w-7" /></span>
|
||||
<span className="min-w-0"><span className="block text-base font-semibold tracking-tight">麦洛</span><span className="classroom-brand-caption">把脑洞变成作品</span></span>
|
||||
</button>}
|
||||
{!workspaceLayout && <ModuleSwitcher sidebarCollapsed={sidebarCollapsed} compact={!sidebarCollapsed} />}
|
||||
</div>
|
||||
</div>}
|
||||
@@ -880,7 +874,7 @@ export function Sidebar({
|
||||
onOpenChange={setProfileDialogOpen}
|
||||
/>
|
||||
|
||||
{workspaceLayout && isProgrammingModule && !isProjectConfigPage && (
|
||||
{workspaceLayout && isProgrammingModule && (
|
||||
<div className="classroom-community-footer">
|
||||
<CommunityLink variant="sidebar" collapsed={sidebarCollapsed} />
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ type SidebarPeekChange = (open: boolean, source: SidebarPeekSource) => void;
|
||||
type TitleBarProps = {
|
||||
integrated?: boolean;
|
||||
workspaceLayout?: boolean;
|
||||
workspaceSidebar?: boolean;
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
@@ -32,11 +33,11 @@ type TitleBarProps = {
|
||||
onSidebarPeekChange?: SidebarPeekChange;
|
||||
};
|
||||
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, overlay = false, showSidebarControls = true, pageTitle, showModuleReturn = false, sidebarPeekOpen = false, onSidebarPeekChange }: TitleBarProps) {
|
||||
export function TitleBar({ integrated = false, workspaceLayout = false, workspaceSidebar = workspaceLayout, 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} showModuleReturn={showModuleReturn} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <ProductTitleBar integrated={integrated} workspaceLayout={workspaceLayout} workspaceSidebar={workspaceSidebar} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} showModuleReturn={showModuleReturn} nativeTrafficLights sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
// Linux keeps its native window frame. Agents still needs an application
|
||||
@@ -45,12 +46,13 @@ export function TitleBar({ integrated = false, workspaceLayout = false, overlay
|
||||
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} showModuleReturn={showModuleReturn} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
return <WindowsTitleBar integrated={integrated} workspaceLayout={workspaceLayout} workspaceSidebar={workspaceSidebar} overlay={overlay} showSidebarControls={showSidebarControls} pageTitle={pageTitle} showModuleReturn={showModuleReturn} sidebarPeekOpen={sidebarPeekOpen} onSidebarPeekChange={onSidebarPeekChange} />;
|
||||
}
|
||||
|
||||
function ProductTitleBar({
|
||||
integrated,
|
||||
workspaceLayout = false,
|
||||
workspaceSidebar = workspaceLayout,
|
||||
overlay = false,
|
||||
showSidebarControls = true,
|
||||
pageTitle,
|
||||
@@ -63,6 +65,7 @@ function ProductTitleBar({
|
||||
}: {
|
||||
integrated: boolean;
|
||||
workspaceLayout?: boolean;
|
||||
workspaceSidebar?: boolean;
|
||||
overlay?: boolean;
|
||||
showSidebarControls?: boolean;
|
||||
pageTitle?: string;
|
||||
@@ -93,10 +96,10 @@ function ProductTitleBar({
|
||||
// The project sidebar preview is rendered below the title bar, so mirror
|
||||
// its full width in the title bar for ordinary pages. The chat workspace
|
||||
// keeps project conversations in the sidebar and must preserve its title rail.
|
||||
const sidebarPreviewTitlebarVisible = sidebarCollapsed && sidebarPeekOpen && !workspaceLayout && !overlay;
|
||||
const sidebarPreviewTitlebarVisible = sidebarCollapsed && sidebarPeekOpen && !workspaceSidebar && !overlay;
|
||||
const sidebarTitlebarHidden = sidebarCollapsed && !sidebarPreviewTitlebarVisible;
|
||||
const agentNavigationWidth = sidebarTitlebarHidden ? (nativeTrafficLights && !nativeFullscreen ? 168 : 88) : 256;
|
||||
const navigationWidth = workspaceLayout ? classroomTitlebarWidth : showModuleReturn ? agentNavigationWidth : undefined;
|
||||
const navigationWidth = workspaceSidebar ? classroomTitlebarWidth : showModuleReturn ? agentNavigationWidth : undefined;
|
||||
const sidebarToggle = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -107,13 +110,13 @@ function ProductTitleBar({
|
||||
className={cn(
|
||||
toolbarButtonClass,
|
||||
'pointer-events-auto absolute top-1 z-[401]',
|
||||
workspaceLayout || showModuleReturn ? 'right-2' : nativeTrafficLights ? 'left-[88px]' : 'left-2',
|
||||
workspaceSidebar || showModuleReturn ? 'right-2' : nativeTrafficLights ? 'left-[88px]' : 'left-2',
|
||||
)}
|
||||
onClick={toggleSidebar}
|
||||
onPointerEnter={() => handleSidebarPeekEnter('toggle')}
|
||||
onPointerLeave={() => handleSidebarPeekLeave('toggle')}
|
||||
onFocus={!workspaceLayout && !showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlur={!workspaceLayout && !showModuleReturn ? () => handleSidebarPeekLeave('focus') : undefined}
|
||||
onFocus={!workspaceSidebar && !showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlur={!workspaceSidebar && !showModuleReturn ? () => handleSidebarPeekLeave('focus') : undefined}
|
||||
>
|
||||
{sidebarCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
|
||||
</button>
|
||||
@@ -123,18 +126,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 && !showModuleReturn && (nativeTrafficLights ? 'w-[132px]' : 'w-[52px]'),
|
||||
!workspaceSidebar && !showModuleReturn && (nativeTrafficLights ? 'w-[132px]' : 'w-[52px]'),
|
||||
)}
|
||||
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) => {
|
||||
onPointerEnter={workspaceSidebar || showModuleReturn ? () => handleSidebarPeekEnter('titlebar') : undefined}
|
||||
onPointerLeave={workspaceSidebar || showModuleReturn ? () => handleSidebarPeekLeave('titlebar') : undefined}
|
||||
onFocusCapture={workspaceSidebar || showModuleReturn ? () => handleSidebarPeekEnter('focus') : undefined}
|
||||
onBlurCapture={workspaceSidebar || showModuleReturn ? (event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) handleSidebarPeekLeave('focus');
|
||||
} : undefined}
|
||||
>
|
||||
{workspaceLayout || showModuleReturn ? <div
|
||||
data-testid={workspaceLayout ? 'classroom-titlebar-navigation' : 'agent-titlebar-navigation'}
|
||||
{workspaceSidebar || showModuleReturn ? <div
|
||||
data-testid={workspaceSidebar ? '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 />
|
||||
@@ -184,7 +187,7 @@ function ProductTitleBar({
|
||||
style={navigationWidth !== undefined ? { width: navigationWidth, minWidth: navigationWidth, flexBasis: navigationWidth } : undefined}
|
||||
className={cn(
|
||||
'flex h-full shrink-0 items-center gap-1 px-2',
|
||||
workspaceLayout
|
||||
workspaceSidebar
|
||||
? ''
|
||||
: sidebarTitlebarHidden
|
||||
? nativeTrafficLights
|
||||
@@ -258,7 +261,7 @@ function ProductTitleBar({
|
||||
);
|
||||
}
|
||||
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarControls, pageTitle, showModuleReturn, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
function WindowsTitleBar({ integrated, workspaceLayout, workspaceSidebar, overlay, showSidebarControls, pageTitle, showModuleReturn, sidebarPeekOpen, onSidebarPeekChange }: Omit<TitleBarProps, 'integrated'> & { integrated: boolean }) {
|
||||
const [maximized, setMaximized] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -288,6 +291,7 @@ function WindowsTitleBar({ integrated, workspaceLayout, overlay, showSidebarCont
|
||||
<ProductTitleBar
|
||||
integrated={integrated}
|
||||
workspaceLayout={workspaceLayout}
|
||||
workspaceSidebar={workspaceSidebar}
|
||||
overlay={overlay}
|
||||
showSidebarControls={showSidebarControls}
|
||||
pageTitle={pageTitle}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/* Approved playful Code workbench. Keep navigation skins scoped to /chat. */
|
||||
[data-testid="main-layout"][data-classroom="true"], .classroom-workspace {
|
||||
/* Shared Code navigation skin; chat content remains scoped to its workspace. */
|
||||
[data-testid="main-layout"][data-classroom="true"], [data-testid="sidebar"][data-classroom="true"], .classroom-workspace {
|
||||
--background: 0 0% 100%; --foreground: 214 34% 23%;
|
||||
--muted-foreground: 214 16% 44%; --border: 213 31% 90%;
|
||||
}
|
||||
[data-testid="main-layout"][data-classroom="true"] [data-testid="titlebar-sidebar-surface"] { background: #f3f7fb; border-color: #dde5ee; box-shadow: none; backdrop-filter: none; }
|
||||
[data-testid="main-layout"][data-code-sidebar="true"] [data-testid="titlebar-sidebar-surface"] { background: #f3f7fb; border-color: #dde5ee; box-shadow: none; backdrop-filter: none; }
|
||||
[data-testid="sidebar"][data-classroom="true"] { background: #f3f7fb; border-color: #dde5ee; box-shadow: none; backdrop-filter: none; color: #26384d; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header { padding-top: 0; padding-bottom: 0; }
|
||||
[data-testid="sidebar"][data-classroom="true"] [data-testid="classroom-sidebar-navigation"] { align-items: center; }
|
||||
@@ -18,7 +18,7 @@
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-brand:hover { background: #eaf1f8; }
|
||||
.classroom-brand-mark { display: grid; place-items: center; width: 40px; height: 42px; flex-shrink: 0; border: 1px solid #f1d1aa; border-radius: 12px; background: #fff0dd; box-shadow: 0 3px 0 #ecdbc5; }
|
||||
.classroom-brand-caption { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: #64758a; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-content { padding: 10px 12px 14px; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-content { padding: 0 12px 14px; }
|
||||
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] { min-height: 48px; gap: 8px; padding: 8px; border: 0; border-radius: 8px; background: transparent; color: #26384d; box-shadow: none; font-size: 14px; font-weight: 600; }
|
||||
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"] svg { color: #c44b23; }
|
||||
[data-testid="sidebar"][data-classroom="true"] [data-testid="sidebar-create-project"]:hover { background: #fff0e6; }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import './classroom-workspace.css';
|
||||
import { CodingChatPanel } from './CodingChatPanel';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { codingRecoveryAccount, readCodingLoginRecovery } from '@/lib/coding-login-recovery';
|
||||
|
||||
@@ -51,6 +51,96 @@ async function seedInactiveCodingProjects(resources: { rootDir: string; userData
|
||||
}
|
||||
|
||||
test.describe('Makelore module navigation without setup flow', () => {
|
||||
test('opens each home module with the sidebar expanded after a saved collapse', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
const page = await getStableWindow(app);
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
await app.evaluate(({ ipcMain, BrowserWindow }, summary) => {
|
||||
const window = BrowserWindow.getAllWindows()[0];
|
||||
window.setSize(1440, 900);
|
||||
const session = () => ({ accessToken: 'home-sidebar-fixture', tokenType: 'Bearer', expiresAt: Date.now() + 3600000, lastActiveAt: Date.now(), canRefresh: false });
|
||||
const send = window.webContents.send.bind(window.webContents);
|
||||
window.webContents.send = (channel: string, ...args: unknown[]) => send(channel, ...(channel === 'auth:session-changed' ? [session()] : args));
|
||||
const result = (json: unknown) => ({ ok: true, data: { status: 200, ok: true, json } });
|
||||
ipcMain.removeHandler('hostapi:fetch');
|
||||
ipcMain.handle('hostapi:fetch', async (_event, request: { path?: string }) => {
|
||||
const path = request.path ?? '';
|
||||
if (path === '/api/auth/session/sync') return result({ success: true, session: session() });
|
||||
if (path === '/api/auth/me') return result({ success: true, user: { username: 'sidebar-child', userId: 'sidebar-user', tenantId: null, deptId: null, authorities: [] }, moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true } });
|
||||
if (['/api/provider-accounts', '/api/provider-accounts/key-info', '/api/provider-vendors', '/api/providers'].includes(path)) return result([]);
|
||||
if (path === '/api/provider-accounts/default') return result({ accountId: null });
|
||||
if (path === '/api/coding/projects') return result({ projects: [], activeProjectId: null });
|
||||
if (path === '/api/settings') return result({ classroomSidebarWidth: 171, sidebarCollapsed: true, setupComplete: true });
|
||||
if (path === '/api/user-sync/bootstrap') return result({ success: true, bootstrap: { preferences: {}, providerAccounts: [], partners: [], usageAggregates: [] } });
|
||||
if (path === '/api/works/user/agent-profile') return result({ success: true, profile: { display_name: '小麦', age: null, gender: null, avatar_url: null, share_age_with_agents: false, share_gender_with_agents: false, analysis_enabled: true, completed: true, version: 1, updated_at: '2026-10-14T00:00:00Z' } });
|
||||
if (path === '/api/works/billing/me') return result({ success: true, data: summary });
|
||||
if (path === '/api/works/image-workspace') return result({ success: true, data: {
|
||||
capabilities: { conversation: true, generation: false, image: false, video: false, generationOptions: {
|
||||
image: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: false, supportedReferenceRoles: [] },
|
||||
video: { available: false, supportedAspectRatios: [], supportedDurationSeconds: [], maxOutputCount: 1, requiresFirstFrame: true, supportedReferenceRoles: [] },
|
||||
} }, workspaces: [],
|
||||
} });
|
||||
if (path.startsWith('/api/cloud-agents/')) {
|
||||
if (path.endsWith('/recovery')) return result({ recent: null, pending: [] });
|
||||
if (path.endsWith('/agents') || path.endsWith('/bootstrap')) return result({ agents: [], next_cursor: null });
|
||||
}
|
||||
return result({ success: true });
|
||||
});
|
||||
}, billingSummary());
|
||||
await page.addInitScript(() => {
|
||||
const now = Date.now();
|
||||
localStorage.setItem('niancode-settings', JSON.stringify({ version: 0, state: { classroomSidebarWidth: 171, sidebarCollapsed: true } }));
|
||||
localStorage.setItem('niancode-auth', JSON.stringify({ version: 4, state: {
|
||||
authBase: 'https://biz.nianxx.cn/auth/', clientId: 'app', accessToken: 'home-sidebar-fixture', tokenType: 'Bearer',
|
||||
expiresAt: now + 3600000, lastActiveAt: now, canRefresh: false, legacyRefreshToken: null,
|
||||
user: { username: 'sidebar-child', userId: 'sidebar-user', tenantId: null, deptId: null, authorities: [] },
|
||||
moduleAccess: { programming: true, design: true, robot: true, cloud_agents: true },
|
||||
} }));
|
||||
localStorage.setItem('niancode-user-agent-profiles', JSON.stringify({ version: 1, state: {
|
||||
profilesByUserId: { 'sidebar-user': { displayName: '小麦', age: null, gender: 'undisclosed', avatarUrl: null,
|
||||
shareAgeWithAgents: false, shareGenderWithAgents: false, analysisEnabled: true,
|
||||
pendingSync: false, version: 1, updatedAt: '2026-10-14T00:00:00Z' } },
|
||||
} }));
|
||||
});
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
const savedCollapsed = () => page.evaluate(() => JSON.parse(localStorage.getItem('niancode-settings')!).state.sidebarCollapsed as boolean);
|
||||
const checkExpanded = async () => {
|
||||
await expect(page.getByTestId('sidebar')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar')).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect.poll(async () => (await page.getByTestId('sidebar').boundingBox())?.width ?? 0).toBeGreaterThan(150);
|
||||
await expect.poll(savedCollapsed).toBe(false);
|
||||
};
|
||||
const collapseAndReturnHome = async () => {
|
||||
await page.getByTestId('titlebar-sidebar-toggle').click();
|
||||
await page.mouse.click(1000, 500);
|
||||
await expect.poll(savedCollapsed).toBe(true);
|
||||
await expect(page.getByTestId('sidebar')).toHaveAttribute('aria-hidden', 'true');
|
||||
await page.getByTestId('sidebar-module-switcher-trigger').click();
|
||||
await expect(page.getByTestId('ai-module-selection-page')).toBeVisible();
|
||||
await expect.poll(savedCollapsed).toBe(true);
|
||||
};
|
||||
for (const [moduleId, route] of [['programming', /\/chat$/], ['cloud_agents', /\/cloud-agents\?view=mine$/]] as const) {
|
||||
await expect.poll(savedCollapsed).toBe(true);
|
||||
await page.getByTestId(`ai-module-option-${moduleId}`).click();
|
||||
await expect(page).toHaveURL(route);
|
||||
await checkExpanded();
|
||||
await collapseAndReturnHome();
|
||||
await page.getByTestId(`ai-module-option-${moduleId}`).click();
|
||||
await expect(page).toHaveURL(route);
|
||||
await checkExpanded();
|
||||
await collapseAndReturnHome();
|
||||
}
|
||||
await page.getByTestId('ai-module-option-painting').click();
|
||||
await expect(page).toHaveURL(/\/image-canvas$/);
|
||||
await checkExpanded();
|
||||
await expect(page.getByTestId('titlebar-sidebar-toggle')).toHaveCount(0);
|
||||
} finally {
|
||||
await app.evaluate(({ BrowserWindow }) => { for (const window of BrowserWindow.getAllWindows()) window.destroy(); }).catch(() => undefined);
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps the home account wallet, profile, settings and logout available without creating a payment', async ({ launchElectronApp }, testInfo) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
try {
|
||||
@@ -286,7 +376,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('opens remembered Code projects and keeps the module return entry in chat instead of project settings', async ({
|
||||
test('opens remembered Code projects and keeps shared Code sidebar chrome in settings', async ({
|
||||
electronTestResources,
|
||||
launchElectronApp,
|
||||
}) => {
|
||||
@@ -320,21 +410,16 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await expect(moduleReturn).toHaveAttribute('aria-label', '返回首页');
|
||||
await expect(moduleReturn).toHaveAttribute('title', '返回首页');
|
||||
await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible();
|
||||
const brandBox = await sidebar.locator('.classroom-brand').boundingBox();
|
||||
await expect(sidebar.locator('.classroom-brand')).toHaveCount(0);
|
||||
await expect(sidebar).not.toContainText('把脑洞变成作品');
|
||||
const returnBox = await moduleReturn.boundingBox();
|
||||
const collapseBox = await page.getByRole('button', { name: '折叠侧栏' }).boundingBox();
|
||||
const sidebarBox = await sidebar.boundingBox();
|
||||
expect(brandBox).not.toBeNull();
|
||||
expect(returnBox).not.toBeNull();
|
||||
expect(collapseBox).not.toBeNull();
|
||||
expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(brandBox!.y);
|
||||
expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(sidebarBox!.y);
|
||||
const headerBox = await page.getByTestId('coding-conversation-header').boundingBox();
|
||||
const tabsBox = await page.getByRole('tablist', { name: '当前工作' }).boundingBox();
|
||||
const toolbarBox = await page.locator('.classroom-workspace-toolbar').boundingBox();
|
||||
expect(Math.abs(returnBox!.y + returnBox!.height / 2 - headerBox!.y - headerBox!.height / 2)).toBeLessThan(1);
|
||||
expect(Math.abs(brandBox!.y + brandBox!.height / 2 - tabsBox!.y - tabsBox!.height / 2)).toBeLessThan(1);
|
||||
expect(brandBox!.y - sidebarBox!.y).toBe(0);
|
||||
expect(brandBox!.height).toBe(toolbarBox!.height);
|
||||
expect(returnBox!.width).toBe(32);
|
||||
expect(Math.abs(returnBox!.y + returnBox!.height / 2 - collapseBox!.y - collapseBox!.height / 2)).toBeLessThan(1);
|
||||
expect(returnBox!.x + returnBox!.width).toBeLessThanOrEqual(collapseBox!.x);
|
||||
@@ -345,10 +430,12 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
const communityBox = await community.boundingBox();
|
||||
const createBox = await sidebar.getByTestId('sidebar-create-project').boundingBox();
|
||||
const headingBox = await sidebar.locator('.classroom-projects-heading').boundingBox();
|
||||
expect(communityBox!.y).toBeGreaterThanOrEqual(brandBox!.y + brandBox!.height);
|
||||
expect(communityBox!.y - brandBox!.y - brandBox!.height).toBe(10);
|
||||
expect(createBox!.y - communityBox!.y - communityBox!.height).toBe(10);
|
||||
expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(communityBox!.height + 20);
|
||||
const accountBox = await sidebar.getByTestId('sidebar-member-menu-trigger').boundingBox();
|
||||
expect(createBox!.y).toBe(sidebarBox!.y);
|
||||
expect(headingBox!.y - sidebarBox!.y).toBe(56);
|
||||
expect(communityBox!.x).toBe(createBox!.x);
|
||||
expect(communityBox!.y).toBeGreaterThan(headingBox!.y + headingBox!.height);
|
||||
expect(accountBox!.y - communityBox!.y - communityBox!.height).toBeCloseTo(0, 0);
|
||||
expect(communityBox!.height).toBe(createBox!.height);
|
||||
await expect(community).toHaveCSS('background-color', 'rgba(0, 0, 0, 0)');
|
||||
const buttonStyles = await page.evaluate(() => {
|
||||
@@ -369,7 +456,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
const fullscreenReturn = await moduleReturn.boundingBox();
|
||||
expect(fullscreenReturn!.y).toBe(returnBox!.y);
|
||||
expect(await page.getByRole('button', { name: '折叠侧栏' }).boundingBox()).toEqual(collapseBox);
|
||||
expect(await sidebar.locator('.classroom-brand').boundingBox()).toEqual(brandBox);
|
||||
expect(await sidebar.getByTestId('sidebar-create-project').boundingBox()).toEqual(createBox);
|
||||
await page.screenshot({ path: test.info().outputPath('chat-fullscreen-return-aligned.png') });
|
||||
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('leave-full-screen'));
|
||||
await expect.poll(async () => (await moduleReturn.boundingBox())!.x).toBe(returnBox!.x);
|
||||
@@ -394,14 +481,76 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await sidebarResizer.dblclick();
|
||||
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '171');
|
||||
|
||||
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/project-config$/);
|
||||
const readCodeChrome = () => page.evaluate(() => {
|
||||
if (document.querySelector('[data-testid="sidebar"] .classroom-brand')) throw new Error('Removed Code brand reappeared');
|
||||
const selectors = {
|
||||
sidebar: '[data-testid="sidebar"]',
|
||||
titlebar: '[data-testid="titlebar-sidebar-surface"]',
|
||||
navigation: '[data-testid="classroom-titlebar-navigation"]',
|
||||
home: '[data-testid="sidebar-module-switcher-trigger"]',
|
||||
toggle: '[data-testid="titlebar-sidebar-toggle"]',
|
||||
create: '[data-testid="sidebar-create-project"]',
|
||||
heading: '[data-testid="sidebar"] .classroom-projects-heading',
|
||||
community: '[data-testid="sidebar"] .community-link[data-variant="sidebar"]',
|
||||
communityIcon: '[data-testid="sidebar"] .community-link-icon',
|
||||
account: '[data-testid="sidebar-member-menu-trigger"]',
|
||||
};
|
||||
return Object.fromEntries(Object.entries(selectors).map(([name, selector]) => {
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) throw new Error('Code chrome element missing: ' + name);
|
||||
const bounds = element.getBoundingClientRect();
|
||||
const style = getComputedStyle(element);
|
||||
const svg = element.querySelector('svg');
|
||||
return [name, {
|
||||
x: Number(bounds.x.toFixed(1)), y: Number(bounds.y.toFixed(1)),
|
||||
width: Number(bounds.width.toFixed(1)), height: Number(bounds.height.toFixed(1)),
|
||||
// The titlebar surface is empty chrome; its inherited text color
|
||||
// belongs to the route's content theme and paints no text or icons.
|
||||
color: name === 'titlebar' ? null : style.color,
|
||||
background: style.backgroundColor, border: style.borderTopColor,
|
||||
radius: style.borderRadius, padding: style.padding,
|
||||
iconWidth: svg ? Number(svg.getBoundingClientRect().width.toFixed(1)) : null,
|
||||
iconStroke: svg?.getAttribute('stroke-width') ?? null,
|
||||
}];
|
||||
}));
|
||||
});
|
||||
// Create local metadata only; no prompt is sent and no model runs.
|
||||
await sidebar.getByRole('button', { name: '新建对话', exact: true }).click();
|
||||
const conversationActions = sidebar.getByRole('button', { name: '对话操作:新对话', exact: true }).first();
|
||||
await expect(conversationActions).toBeAttached();
|
||||
await page.mouse.move(1000, 500);
|
||||
const chatChrome = await readCodeChrome();
|
||||
await conversationActions.hover();
|
||||
await conversationActions.click();
|
||||
await page.getByRole('menuitem', { name: '会话编程设置', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/project-config\?agent=/);
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0);
|
||||
await expect(moduleReturn).toBeVisible();
|
||||
const projectBack = page.getByTestId('project-configuration-back-button');
|
||||
await expect(projectBack).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath('project-config-without-module-return.png') });
|
||||
await page.mouse.move(1000, 500);
|
||||
await expect.poll(readCodeChrome).toEqual(chatChrome);
|
||||
await expect(sidebar).toHaveCSS('background-color', 'rgb(243, 247, 251)');
|
||||
const prompt = page.getByLabel('项目提示词', { exact: true });
|
||||
await expect(prompt).toBeVisible();
|
||||
const originalPrompt = await prompt.inputValue();
|
||||
await page.screenshot({ path: test.info().outputPath('project-config-shared-code-sidebar.png') });
|
||||
|
||||
// Reload directly on the settings URL, without mounting /chat first.
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await expect(page).toHaveURL(/\/project-config\?agent=/);
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect.poll(readCodeChrome).toEqual(chatChrome);
|
||||
const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle');
|
||||
await sidebarToggle.click();
|
||||
await page.getByTestId('project-configuration-page').click({ position: { x: 500, y: 20 } });
|
||||
await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
|
||||
await expect(moduleReturn).toBeVisible();
|
||||
await sidebarToggle.click();
|
||||
await page.mouse.move(1000, 500);
|
||||
await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
|
||||
await expect.poll(readCodeChrome).toEqual(chatChrome);
|
||||
await expect(prompt).toHaveValue(originalPrompt);
|
||||
await projectBack.click();
|
||||
await expect(page).toHaveURL(/\/chat$/);
|
||||
await expect(moduleReturn).toBeVisible();
|
||||
@@ -412,8 +561,9 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await expect(page).toHaveURL(/\/project-config\/plugins\?/);
|
||||
const plugins = page.getByTestId('project-plugins-sheet');
|
||||
await expect(plugins).toBeVisible();
|
||||
await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0);
|
||||
await page.screenshot({ path: test.info().outputPath('project-config-plugins-without-module-return.png') });
|
||||
await expect(moduleReturn).toBeVisible();
|
||||
await expect.poll(readCodeChrome).toEqual(chatChrome);
|
||||
await page.screenshot({ path: test.info().outputPath('project-config-plugins-shared-code-sidebar.png') });
|
||||
await plugins.getByRole('button', { name: '关闭', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/project-config(?:\?|$)/);
|
||||
await expect(plugins).toHaveCount(0);
|
||||
@@ -422,7 +572,6 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await sidebar.getByTestId('sidebar-course-project-project-storybook').click();
|
||||
await expect(page).toHaveURL(/\/chat$/);
|
||||
|
||||
const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle');
|
||||
await sidebarToggle.click();
|
||||
await page.getByTestId('coding-chat-panel').click({ position: { x: 500, y: 200 } });
|
||||
await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
|
||||
@@ -599,7 +748,7 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await expect(page.getByTestId('coding-existing-projects')).toHaveCount(0);
|
||||
await expect(page.getByText('一念成光,万物可创。')).toHaveCount(0);
|
||||
await expect(page.getByRole('img', { name: 'Makelore logo' })).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar').locator('.classroom-brand')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar').locator('.classroom-brand')).toHaveCount(0);
|
||||
await expect(page.getByTestId('sidebar-module-switcher-trigger')).toHaveText('');
|
||||
await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible();
|
||||
await expect(page.getByTestId('sidebar-create-project')).toBeVisible();
|
||||
|
||||
@@ -189,12 +189,23 @@ describe('ImageWorkspaceSidebar', () => {
|
||||
expect(actions.renameProject).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects a project by its workspace identity from the project row', () => {
|
||||
const { target, actions } = prepareProjects();
|
||||
it('shows only project titles under My Projects and keeps recent-first selection by workspace identity', () => {
|
||||
const { workspace, target, actions } = prepareProjects();
|
||||
renderSidebar();
|
||||
|
||||
const sidebar = screen.getByTestId('sidebar-image-workspace');
|
||||
fireEvent.click(within(sidebar).getByRole('button', { name: new RegExp(`^${target.title}`) }));
|
||||
expect(within(sidebar).getByText('我的项目', { exact: true })).toBeInTheDocument();
|
||||
expect(within(sidebar).queryByText('设计项目', { exact: true })).not.toBeInTheDocument();
|
||||
expect(sidebar).not.toHaveTextContent('设计版本');
|
||||
const activeProject = within(sidebar).getByRole('button', { name: workspace.workspace.title, exact: true });
|
||||
const newerProject = within(sidebar).getByRole('button', { name: target.title, exact: true });
|
||||
expect(activeProject.textContent).toBe(workspace.workspace.title);
|
||||
expect(newerProject.textContent).toBe(target.title);
|
||||
expect(activeProject).toHaveAttribute('aria-current', 'page');
|
||||
// The September project is newer than the active August project, despite
|
||||
// arriving second in bootstrap. Removing its date must not change order.
|
||||
expect(newerProject.compareDocumentPosition(activeProject)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
fireEvent.click(newerProject);
|
||||
|
||||
expect(actions.selectProject).toHaveBeenCalledExactlyOnceWith(target.workspaceId);
|
||||
expect(screen.getByLabelText('当前页面')).toHaveTextContent('/image-canvas');
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
||||
import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ModuleSwitcher } from '@/components/layout/ModuleSwitcher';
|
||||
import { ModuleSelection } from '@/pages/ModuleSelection';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { codingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useUserProfileStore } from '@/stores/user-profile';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
const { hostApiFetchMock } = vi.hoisted(() => ({
|
||||
hostApiFetchMock: vi.fn(),
|
||||
@@ -20,9 +21,22 @@ function LocationProbe() {
|
||||
return <output data-testid="location-path">{location.pathname}</output>;
|
||||
}
|
||||
|
||||
function SidebarEntryProbe() {
|
||||
const collapsed = useSettingsStore((state) => state.sidebarCollapsed);
|
||||
const setCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
|
||||
const navigate = useNavigate();
|
||||
return <>
|
||||
<LocationProbe />
|
||||
<output data-testid="sidebar-state">{collapsed ? 'collapsed' : 'expanded'}</output>
|
||||
<button onClick={() => setCollapsed(!collapsed)}>Toggle sidebar</button>
|
||||
<button onClick={() => navigate('/module-select')}>Home</button>
|
||||
</>;
|
||||
}
|
||||
|
||||
describe('AI module navigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useSettingsStore.setState({ sidebarCollapsed: true });
|
||||
useAuthStore.setState({
|
||||
initialized: true,
|
||||
loading: false,
|
||||
@@ -52,6 +66,32 @@ describe('AI module navigation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['programming', '/chat'],
|
||||
['cloud_agents', '/cloud-agents'],
|
||||
['painting', '/image-canvas'],
|
||||
])('expands %s on every home entry while allowing manual collapse', async (moduleId, route) => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/module-select']}>
|
||||
<Routes>
|
||||
<Route path="/module-select" element={<ModuleSelection />} />
|
||||
<Route path="*" element={<SidebarEntryProbe />} />
|
||||
</Routes>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId(`ai-module-option-${moduleId}`));
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent(route);
|
||||
expect(screen.getByTestId('sidebar-state')).toHaveTextContent('expanded');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Toggle sidebar' }));
|
||||
expect(screen.getByTestId('sidebar-state')).toHaveTextContent('collapsed');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Home', exact: true }));
|
||||
expect(useSettingsStore.getState().sidebarCollapsed).toBe(true);
|
||||
fireEvent.click(screen.getByTestId(`ai-module-option-${moduleId}`));
|
||||
expect(await screen.findByTestId('sidebar-state')).toHaveTextContent('expanded');
|
||||
});
|
||||
|
||||
it('shows Agents, Code and Canvas on the home page and routes Canvas from the chooser', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/module-select']}>
|
||||
@@ -144,6 +184,7 @@ describe('AI module navigation', () => {
|
||||
fireEvent.click(canvasOption);
|
||||
expect(screen.getByTestId('ai-module-selection-page')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('location-path')).not.toBeInTheDocument();
|
||||
expect(useSettingsStore.getState().sidebarCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('routes AI programming from the chooser to the project conversation entry', async () => {
|
||||
@@ -184,6 +225,7 @@ describe('AI module navigation', () => {
|
||||
|
||||
fireEvent.click(screen.getByTestId('ai-module-option-programming'));
|
||||
expect(await screen.findByTestId('location-path')).toHaveTextContent('/login');
|
||||
expect(useSettingsStore.getState().sidebarCollapsed).toBe(true);
|
||||
});
|
||||
|
||||
it('returns to the module chooser from the Sidebar entry', async () => {
|
||||
|
||||
Reference in new issue
Block a user