diff --git a/.project-docs/30-worklog/tasks/20260929-code-back-entry-33d87a81.md b/.project-docs/30-worklog/tasks/20260929-code-back-entry-33d87a81.md index 9801247e..79996cc1 100644 --- a/.project-docs/30-worklog/tasks/20260929-code-back-entry-33d87a81.md +++ b/.project-docs/30-worklog/tasks/20260929-code-back-entry-33d87a81.md @@ -8,11 +8,11 @@ - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-empty-work-merge-39c633d0 - Base commit: a77ddbd6813bb71d490544edfef6e1f35240070f - Owner: codex -- Status: Ready for Integration +- Status: Planning ## Scope -- Move the existing “编程 Code” module-home return entry from the project configuration sidebar into the Coding chat/workspace sidebar as an icon-only arrow in the top navigation row above the brand. Retain its `/module-select` target and the configuration page's own back action. +- Move the existing “编程 Code” module-home return entry from the project configuration sidebar into the Coding workspace titlebar as an icon-only arrow aligned with the conversation header, with the brand aligned to the tabs below. Retain its `/module-select` target and the configuration page's own back action. ## Intent And Constraints @@ -29,7 +29,7 @@ ## Outcome -- Reused the existing ModuleSwitcher as an icon-only arrow at the left of the top navigation row above the brand in the `/chat` sidebar, aligned with the collapse control on the right, retaining its accessible return-home label and `/module-select` action. Project configuration and nested resource pages omit the entire sidebar header block, so no empty placeholder remains. Existing configuration back action, sidebar resizing/peek and other module controls are unchanged. README synchronized. +- Reused the existing ModuleSwitcher as an icon-only arrow in the `/chat` titlebar, aligned with the collapse control and the right-side conversation header, retaining its accessible return-home label and `/module-select` action. Project configuration and nested resource pages omit the entire sidebar header block, so no empty placeholder remains. Existing configuration back action, sidebar resizing/peek and other module controls are unchanged. README synchronized. ## Verification @@ -43,7 +43,7 @@ ## Promotion Candidates -- Target: canonical classroom navigation when next integrated. Proposal: the icon-only module-home return is visible above the chat sidebar brand, aligned with the right-side collapse control and omitted from project configuration/resource sidebars; original configuration back remains. Evidence: explicit user confirmation, existing component reuse, actual Electron navigation and screenshots. No architecture or module-target change; no further product confirmation required. +- Target: canonical classroom navigation when next integrated. Proposal: the icon-only module-home return and collapse control align with the conversation header, with branding aligned to the tabs below and omitted from project configuration/resource sidebars; original configuration back remains. Evidence: explicit user confirmation, existing component reuse, actual Electron navigation and screenshots. No architecture or module-target change; no further product confirmation required. ## Follow-up: Update The Temporary Renderer @@ -125,3 +125,13 @@ - Updated the live trial using a route-stylesheet swap, with no Renderer reload or Main restart. Candidate based on the finalized composer-height source 66cad87, retaining colored shortcuts and the full icon-only navigation. Sole source difference is the sidebar CSS from b4c06e8; composite typecheck/PostCSS production build and independent code review passed. Final source: `sidebar-spacing-33d87a81/source`; active route CSS `index-CjmETxm0.css`, existing running JS `index-CEjIKC5e.js`, next-load JS `index-BU5aR8dW.js`, common CSS `index-CDJ7LmpW.css`. Served index and launch/guidance provenance are updated together, distinguishing running JS from prepared next-load JS. - Live before/after geometry confirms both controls moved up exactly 4px, brand and New Project boxes identical, Works Square down 10px with 10px gaps above/below and the requested filled background. Controls remain aligned and unclipped. All local storage, messages, input drafts, teacher content, selected tab, DOM focus, native visibility/focus and 34 recorded data-file hashes are unchanged; zero alerts. Main PID 32583/profile retained; inspector opened for this update was closed. Temporary app is ready; no main merge, push or formal-app modification. + + +## Follow-up: Align Sidebar With The Two Workspace Header Rows + +- User now explicitly asks for the return/collapse icons to align with the right-side project/conversation header, the Makelore brand to align with Operation Chat/Work tabs, and a lighter Works Square button matching New Project styling with equal spacing. This supersedes keeping the brand at its former lower position. Continue the authorized temporary-app update. +- Project Context Loaded / Planning Gate: Passed. Official same-task feature resume verified exact Identity fields; active record, entry/memory index and peer scopes read. Shared positioning, decisions, architecture/domain, evidence/reflection and commitments remain unchanged from 9d47cd2. Composer height is now published and ready; a separate merge task touches only main/cleanup and excludes live-app updates. Current published trial source is sidebar-spacing-33d87a81/source with all 13 overlays, so no semantic conflict. Task owns navigation presentation and focused regression/docs only; preserve light theme, native window controls, local projects, conversations and teacher features. +- Plan: move chat return/collapse into the native-titlebar-height navigation rail, using shared safe-width math for the right conversation header. macOS reserves 88px for traffic lights and a minimum 168px for the full pair; Windows minimum 88px. Keep both controls accessible while collapsed and stable during peek; the sidebar itself still supports 128px. Remove the redundant chat-sidebar row on platforms with a custom titlebar, retain Linux fallback, align a 46px brand block to the 46px tabs, and match Works Square to New Project's 41px rounded/bordered button with a very pale fill and equal 10px gaps. Validate platform controls, collapse/peek, narrow widths and actual two-row geometry, then build the latest composite and update the existing trial. + +- Implemented the two-control titlebar rail and shared safe-width helper; removed the redundant sidebar row on macOS/Windows while keeping Linux fallback. The brand is 46px tall, aligned to the tabs. Works Square is a 41px very pale green button matching New Project border/radius/padding/font/shadow, with equal 10px gaps. Keyboard focus moves between the titlebar buttons without closing the sidebar preview. +- Verification: typecheck, scoped ESLint and production build passed. Titlebar/platform/header/peek unit coverage: 33 tests in 3 files passed. Existing Electron navigation E2E passed (1/1), verifying both horizontal alignments, exact matching button styles, 128px sidebar with reserved topbar space, actual return hit testing, configuration/nested absence, stable collapsed hover geometry and real return navigation. Default and narrow screenshots inspected. Composite build/typecheck/lint passed; full source comparison confirms only the five intended product files changed and all teacher/composer overlays retained. Temporary publication pending below. diff --git a/README.md b/README.md index 295b2960..a4a31701 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,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`。左侧栏顶部提供同排图标:左侧仅显示返回箭头,点击返回模块选择首页,右侧收起侧栏;返回按钮保留“返回首页”提示,折叠后的展开入口保持在标题栏。图标行向上对齐,麦洛品牌位置保持不变;浅绿色底的“作品广场”位于品牌与“新建项目”之间,上下各留 10px;“我的项目”和项目列表紧接“新建项目”,将公共导航与个人项目操作分组;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 M 标识和居中引导语建立焦点,下方提供独立的“新增项目”主操作以及可横向浏览、直接进入的已有项目卡片;项目创建和进入仍复用侧栏现有流程。已有项目的新 Conversation 在可编辑 Composer 上方沿用同一品牌引导,第一条消息出现后切换为正常时间线。 +- AI 编程默认入口:进入 Code 后保持在 `/chat`。顶部返回箭头与侧栏收起图标同排,与右侧项目/会话名称对齐;折叠时保留返回和展开入口。macOS 为原生窗口按钮保留安全空间,窄侧栏仍可使用。下方麦洛品牌与“操作对话/作品”标签行对齐;更浅底色的“作品广场”与“新建项目”采用相同高度、圆角、边框与阴影,并位于品牌和新建项目之间,上下各留 10px;“我的项目”和项目列表紧接新建项目;项目配置及其资源子页不再重复显示此侧栏入口,保留配置页自身的返回操作。尚未选择本地项目时,页面以麦洛 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 永久点数 API 与 0094 迁移;本地模拟验收不代表真实支付通过。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 78da8009..874529f0 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -11,7 +11,6 @@ import { FolderKanban, Gamepad2, LogOut, - PanelLeftClose, Plus, RadioTower, Settings as SettingsIcon, @@ -91,7 +90,6 @@ export function Sidebar({ }: SidebarProps = {}) { const { t } = useTranslation('common'); const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); - const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth)); const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed; const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen; @@ -415,17 +413,8 @@ export function Sidebar({ )} >
- {workspaceLayout &&
+ {workspaceLayout && !['darwin', 'win32'].includes(window.electron?.platform ?? '') &&
- {!sidebarPinnedCollapsed && ['darwin', 'win32'].includes(window.electron?.platform ?? '') && }
} {workspaceLayout && @@ -110,10 +112,23 @@ function ProductTitleBar({ data-testid="titlebar-sidebar-toggle-layer" className={cn( 'no-drag pointer-events-auto fixed left-0 top-0 z-[400] h-10', - nativeTrafficLights ? 'w-[132px]' : 'w-[52px]', + !workspaceLayout && (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) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) handleSidebarPeekLeave('focus'); + } : undefined} > - {sidebarToggle} + {workspaceLayout ?
+ + {sidebarToggle} +
: sidebarToggle}
); const titlebarLogo = ( @@ -155,16 +170,16 @@ function ProductTitleBar({ <>
handleSidebarPeekEnter('titlebar') : undefined} onPointerLeave={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekLeave('titlebar') : undefined} /> - {(!workspaceLayout || sidebarCollapsed) && (typeof document !== 'undefined' ? createPortal(sidebarTogglePortal, document.body) : sidebarTogglePortal)} + {typeof document !== 'undefined' ? createPortal(sidebarTogglePortal, document.body) : sidebarTogglePortal} ) : null} {integrated ? ( diff --git a/src/components/layout/titlebar-metrics.ts b/src/components/layout/titlebar-metrics.ts index 8dd8037d..60884449 100644 --- a/src/components/layout/titlebar-metrics.ts +++ b/src/components/layout/titlebar-metrics.ts @@ -1,4 +1,13 @@ +import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; + export const TITLEBAR_LOGO_WIDTH = 96; // Three 44px buttons plus the control container's 8px padding on each side. export const WINDOWS_TITLEBAR_CONTROLS_WIDTH = 148; + +/** Keep the Code navigation and conversation header outside each other's hit areas. */ +export function getClassroomTitlebarWidth(platform: string | undefined, sidebarCollapsed: boolean, sidebarWidth: number): number { + // Two 32px controls, an 8px gap/right inset, and the native traffic-light safe area. + const minimum = platform === 'darwin' ? 168 : 88; + return sidebarCollapsed ? minimum : Math.max(minimum, normalizeClassroomSidebarWidth(sidebarWidth)); +} diff --git a/src/pages/Chat/CodingConversationHeader.tsx b/src/pages/Chat/CodingConversationHeader.tsx index 3e077f17..140c3b21 100644 --- a/src/pages/Chat/CodingConversationHeader.tsx +++ b/src/pages/Chat/CodingConversationHeader.tsx @@ -14,7 +14,7 @@ import { CodingConversationRenameDialog } from './CodingConversationRenameDialog import { cn } from '@/lib/utils'; import { useSettingsStore } from '@/stores/settings'; import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; -import { WINDOWS_TITLEBAR_CONTROLS_WIDTH } from '@/components/layout/titlebar-metrics'; +import { getClassroomTitlebarWidth, WINDOWS_TITLEBAR_CONTROLS_WIDTH } from '@/components/layout/titlebar-metrics'; import type { ConversationSnapshot } from '@/types/coding-conversation'; import type { CodingConversationMetadata } from '@/types/coding-project'; @@ -189,7 +189,7 @@ export function CodingConversationHeader({ className="pointer-events-none fixed right-0 top-0 z-[80] h-10" data-testid="coding-conversation-header-titlebar" style={{ - left: `${sidebarCollapsed ? (platform === 'darwin' ? 132 : 52) : Math.max(sidebarWidth, platform === 'darwin' ? 132 : 0)}px`, + left: `${getClassroomTitlebarWidth(platform, sidebarCollapsed, sidebarWidth)}px`, // Keep both actions and their hit area outside the product chrome. right: platform === 'win32' ? WINDOWS_TITLEBAR_CONTROLS_WIDTH : 0, }} diff --git a/src/pages/Chat/classroom-workspace.css b/src/pages/Chat/classroom-workspace.css index de12a66e..c6e3c7e7 100644 --- a/src/pages/Chat/classroom-workspace.css +++ b/src/pages/Chat/classroom-workspace.css @@ -6,10 +6,10 @@ [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="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: flex-start; } -[data-testid="sidebar"][data-classroom="true"] .classroom-brand { gap: 10px; padding: 0 2px 8px; } -[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 36px; margin-top: 10px; gap: 4px; padding: 6px 4px; border-color: #cfddc3; border-radius: 11px; background: #e8f0df; } -[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #dfeacf; border-color: #b7c6a4; } +[data-testid="sidebar"][data-classroom="true"] [data-testid="classroom-sidebar-navigation"] { align-items: center; } +[data-testid="sidebar"][data-classroom="true"] .classroom-brand { height: 46px; gap: 10px; padding: 0 2px; } +[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"] { min-height: 41px; margin-top: 10px; gap: 9px; padding: 8px 11px; border: 1px solid #d5e1ed; border-radius: 11px; background: #f8faf5; box-shadow: 0 3px 0 #e3eaf3; font-size: 14px; font-weight: 500; line-height: 20px; white-space: normal; } +[data-testid="sidebar"][data-classroom="true"] .classroom-sidebar-header .community-link[data-variant="sidebar"]:hover:not(:disabled) { background: #eef3e7; border-color: #b7c6a4; } [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; } diff --git a/tests/e2e/main-navigation.spec.ts b/tests/e2e/main-navigation.spec.ts index 5d1e081f..2f232efc 100644 --- a/tests/e2e/main-navigation.spec.ts +++ b/tests/e2e/main-navigation.spec.ts @@ -78,22 +78,26 @@ test.describe('Makelore module navigation without setup flow', () => { await expect(page.getByTestId('sidebar-course-project-project-storybook')).toHaveAttribute('aria-current', 'page'); const sidebar = page.getByTestId('sidebar'); - const moduleReturn = sidebar.getByTestId('sidebar-module-switcher-trigger'); + const moduleReturn = page.getByTestId('sidebar-module-switcher-trigger'); await expect(moduleReturn).toBeVisible(); await expect(moduleReturn).toHaveText(''); await expect(moduleReturn).toHaveAttribute('aria-label', '返回首页'); await expect(moduleReturn).toHaveAttribute('title', '返回首页'); - await expect(sidebar.getByTestId('sidebar-module-return-icon')).toBeVisible(); + await expect(page.getByTestId('sidebar-module-return-icon')).toBeVisible(); const brandBox = await sidebar.locator('.classroom-brand').boundingBox(); const returnBox = await moduleReturn.boundingBox(); - const collapseBox = await sidebar.getByRole('button', { name: '折叠侧栏' }).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 - sidebarBox!.y).toBe(0); - expect(brandBox!.y - sidebarBox!.y).toBe(40); + const headerBox = await page.getByTestId('coding-conversation-header').boundingBox(); + const tabsBox = await page.getByRole('tablist', { name: '当前工作' }).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(tabsBox!.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); @@ -107,8 +111,17 @@ test.describe('Makelore module navigation without setup flow', () => { 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(56); - await expect(community).toHaveCSS('background-color', 'rgb(232, 240, 223)'); + expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(61); + expect(communityBox!.height).toBe(createBox!.height); + await expect(community).toHaveCSS('background-color', 'rgb(248, 250, 245)'); + const buttonStyles = await page.evaluate(() => { + const styles = (selector: string) => { + const s = getComputedStyle(document.querySelector(selector)!); + return [s.borderRadius, s.borderWidth, s.borderColor, s.boxShadow, s.padding, s.fontSize, s.fontWeight]; + }; + return { community: styles('[data-testid=sidebar] .community-link'), create: styles('[data-testid=sidebar-create-project]') }; + }); + expect(buttonStyles.community).toEqual(buttonStyles.create); expect(headingBox!.y).toBeGreaterThanOrEqual(createBox!.y + createBox!.height); expect(headingBox!.y - createBox!.y - createBox!.height).toBeLessThanOrEqual(14); await page.screenshot({ path: test.info().outputPath('chat-code-return-entry.png') }); @@ -117,10 +130,16 @@ test.describe('Makelore module navigation without setup flow', () => { await sidebarResizer.press('Home'); await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '128'); const narrowReturn = await moduleReturn.boundingBox(); - const narrowToggle = await sidebar.getByRole('button', { name: '折叠侧栏' }).boundingBox(); + const narrowToggle = await page.getByRole('button', { name: '折叠侧栏' }).boundingBox(); expect(narrowReturn!.x + narrowReturn!.width).toBeLessThanOrEqual(narrowToggle!.x); await expect(moduleReturn).toBeVisible(); - await expect(sidebar.getByRole('button', { name: '折叠侧栏' })).toBeVisible(); + await expect(page.getByRole('button', { name: '折叠侧栏' })).toBeVisible(); + const narrowHeader = await page.getByTestId('coding-conversation-header').boundingBox(); + expect(narrowToggle!.x + narrowToggle!.width).toBeLessThanOrEqual(narrowHeader!.x); + expect(await moduleReturn.evaluate(el => { + const b = el.getBoundingClientRect(); + return el.contains(document.elementFromPoint(b.x + b.width / 2, b.y + b.height / 2)); + })).toBe(true); await expect(community).toBeVisible(); expect(await community.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); await page.screenshot({ path: test.info().outputPath('chat-narrow-navigation-row.png') }); @@ -155,8 +174,11 @@ test.describe('Makelore module navigation without setup flow', () => { await sidebarToggle.click(); await page.getByTestId('coding-chat-panel').click({ position: { x: 500, y: 200 } }); await expect(sidebar).toHaveAttribute('aria-hidden', 'true'); + const collapsedToggleBox = await sidebarToggle.boundingBox(); + await expect(moduleReturn).toBeVisible(); await sidebarToggle.hover(); await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); + expect(await sidebarToggle.boundingBox()).toEqual(collapsedToggleBox); await expect(moduleReturn).toBeVisible(); await page.screenshot({ path: test.info().outputPath('chat-collapsed-hover-code-return.png') }); await moduleReturn.click(); diff --git a/tests/unit/coding-feature-ui.test.tsx b/tests/unit/coding-feature-ui.test.tsx index 23d1b72b..f5766686 100644 --- a/tests/unit/coding-feature-ui.test.tsx +++ b/tests/unit/coding-feature-ui.test.tsx @@ -267,7 +267,7 @@ describe('PI-130 feature-complete Coding UI', () => { ); expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveClass('fixed', 'top-0', 'h-10'); - expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '132px' }); + expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: '168px' }); expect(screen.queryByTestId('coding-conversation-sidebar')).not.toBeInTheDocument(); window.electron.platform = originalPlatform; diff --git a/tests/unit/title-bar.test.tsx b/tests/unit/title-bar.test.tsx index 49c9ad30..b63ffd77 100644 --- a/tests/unit/title-bar.test.tsx +++ b/tests/unit/title-bar.test.tsx @@ -1,8 +1,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import type { ReactElement } from 'react'; +import { act, fireEvent, render as renderComponent, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, useLocation } from 'react-router-dom'; import { TitleBar } from '@/components/layout/TitleBar'; +import { CodingConversationHeader } from '@/pages/Chat/CodingConversationHeader'; import { useSettingsStore } from '@/stores/settings'; +function render(ui: ReactElement) { + return renderComponent(ui, { wrapper: ({ children }) => {children} }); +} + +function LocationPath() { + return {useLocation().pathname}; +} + const invokeIpcMock = vi.hoisted(() => vi.fn()); vi.mock('@/lib/api-client', () => ({ @@ -91,7 +102,10 @@ describe('TitleBar platform behavior', () => { expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument(); expect(screen.queryByRole('img', { name: 'Makelore logo' })).not.toBeInTheDocument(); expect(screen.queryByTestId('titlebar-project-context')).not.toBeInTheDocument(); - expect(screen.queryByRole('button', { name: '折叠侧栏' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '折叠侧栏' })).toBeEnabled(); + expect(screen.getByRole('button', { name: '返回首页' })).toBeEnabled(); + expect(screen.getByTestId('classroom-titlebar-navigation')).toHaveClass('pl-[88px]'); + expect(screen.getByTestId('titlebar-sidebar-toggle')).toHaveClass('right-2'); }); it('renders the painting title bar as a transparent overlay with the logo at the window edge', () => { @@ -179,14 +193,14 @@ describe('TitleBar platform behavior', () => { useSettingsStore.setState({ sidebarCollapsed: true }); const { rerender } = render(); - expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveClass('w-[132px]'); + expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: '168px' }); expect(screen.queryByTestId('titlebar-conversation-surface')).not.toBeInTheDocument(); expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: '展开侧栏' })).toHaveAttribute('aria-expanded', 'false'); rerender(); - expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveClass('w-[132px]'); + expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: '168px' }); expect(screen.queryByTestId('titlebar-conversation-surface')).not.toBeInTheDocument(); expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument(); const pinButton = screen.getByRole('button', { name: '固定侧栏' }); @@ -195,7 +209,67 @@ describe('TitleBar platform behavior', () => { fireEvent.click(pinButton); expect(useSettingsStore.getState().sidebarCollapsed).toBe(false); expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: '171px', minWidth: '171px', flexBasis: '171px' }); - expect(screen.queryByRole('button', { name: '折叠侧栏' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: '折叠侧栏' })).toBeEnabled(); + }); + + it.each([ + ['darwin', 168, 168, 'pl-[88px]'], + ['win32', 128, 88, 'pl-2'], + ] as const)('keeps both %s navigation controls outside the conversation header at narrow and collapsed widths', (platform, narrowWidth, collapsedWidth, insetClass) => { + window.electron.platform = platform; + useSettingsStore.setState({ classroomSidebarWidth: 128 }); + render(<> + + + + ); + + const assertWidth = (width: number) => { + expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: `${width}px` }); + expect(screen.getByTestId('titlebar-sidebar-toggle-layer')).toHaveStyle({ width: `${width}px` }); + expect(screen.getByTestId('coding-conversation-header-titlebar')).toHaveStyle({ left: `${width}px` }); + }; + assertWidth(narrowWidth); + const rail = screen.getByTestId('classroom-titlebar-navigation'); + expect(rail).toHaveClass(insetClass); + expect(rail).toContainElement(screen.getByRole('button', { name: '返回首页' })); + expect(rail).toContainElement(screen.getByRole('button', { name: '折叠侧栏' })); + expect(screen.getByRole('button', { name: '返回首页' })).toHaveTextContent(''); + expect(screen.getAllByTestId('titlebar-sidebar-toggle')).toHaveLength(1); + + act(() => useSettingsStore.setState({ classroomSidebarWidth: 256 })); + assertWidth(256); + fireEvent.click(screen.getByRole('button', { name: '折叠侧栏' })); + assertWidth(collapsedWidth); + fireEvent.click(screen.getByRole('button', { name: '返回首页' })); + expect(screen.getByTestId('location-path')).toHaveTextContent('/module-select'); + }); + + it('preserves keyboard preview when focus moves between the classroom controls', () => { + window.electron.platform = 'darwin'; + useSettingsStore.setState({ sidebarCollapsed: true }); + const onSidebarPeekChange = vi.fn(); + render(<> + + + ); + const toggle = screen.getByRole('button', { name: '固定侧栏' }); + const back = screen.getByRole('button', { name: '返回首页' }); + act(() => toggle.focus()); + expect(onSidebarPeekChange).toHaveBeenLastCalledWith(true, 'focus'); + onSidebarPeekChange.mockClear(); + act(() => back.focus()); + expect(onSidebarPeekChange).not.toHaveBeenCalledWith(false, 'focus'); + expect(onSidebarPeekChange).toHaveBeenLastCalledWith(true, 'focus'); + act(() => screen.getByRole('button', { name: 'Outside navigation' }).focus()); + expect(onSidebarPeekChange).toHaveBeenLastCalledWith(false, 'focus'); + + const layer = screen.getByTestId('titlebar-sidebar-toggle-layer'); + fireEvent.pointerEnter(layer); + fireEvent.pointerLeave(layer); + expect(onSidebarPeekChange).toHaveBeenCalledWith(true, 'titlebar'); + expect(onSidebarPeekChange).toHaveBeenLastCalledWith(false, 'titlebar'); + expect(layer).toHaveStyle({ width: '168px' }); }); it('extends the ordinary titlebar sidebar surface during a hover preview', () => {