Align Code navigation and branding with workspace header rows

This commit is contained in:
鲨鱼辣椒
2026-09-30 00:39:24 +08:00
parent 9d47cd2146
commit 883ae1d788
10 changed files with 169 additions and 50 deletions

View File

@@ -8,11 +8,11 @@
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-empty-work-merge-39c633d0 - Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-empty-work-merge-39c633d0
- Base commit: a77ddbd6813bb71d490544edfef6e1f35240070f - Base commit: a77ddbd6813bb71d490544edfef6e1f35240070f
- Owner: codex - Owner: codex
- Status: Ready for Integration - Status: Planning
## Scope ## 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 ## Intent And Constraints
@@ -29,7 +29,7 @@
## Outcome ## 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 ## Verification
@@ -43,7 +43,7 @@
## Promotion Candidates ## 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 ## 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. - 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. - 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.

View File

@@ -31,7 +31,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智
- macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。 - macOS 窗口按钮:红色关闭当前窗口,全屏时先退出全屏再关闭;黄色最小化,绿色保留原生缩放/全屏行为。关窗后应用和进行中的后台任务继续运行,点击 Dock、托盘或菜单可重新打开窗口;`Cmd+Q` 退出应用。
- 桌面文本右键菜单:主 Renderer 窗口由 Electron Main 提供原生“剪切、复制、粘贴、全选”操作;菜单只在可编辑区域或已有文本选择时出现,普通空白区域不显示,也不向 Renderer 暴露剪贴板 IPC。 - 桌面文本右键菜单:主 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 编程核心对话运行时: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。 - 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 迁移;本地模拟验收不代表真实支付通过。 - 账号词元点数:左下角账户菜单显示本人永久余额和预占,提供充值、充值订单与分页收支记录。真正新注册赠送 100 点,1 元充值 50 点,永久有效;旧账户不补送,旧会员/订阅/周额度/重置卡入口已移除。家长充值始终进入自己的钱包,青少年可查看自己的精确余额;使用他人共享钱包时仅显示可用状态,不显示付款人的余额或记录。Main 持有凭据并转发充值请求;付款结果未知时沿用原请求并核查原订单,只有服务端确认才显示到账。扫码信息缺失时可到网页账户页继续原订单,付款来源也由网页账户页管理。需配套部署 Works Square 永久点数 API 与 0094 迁移;本地模拟验收不代表真实支付通过。
- 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。 - 桌面性能策略:应用窗口使用不透明浅色表面并默认保留硬件合成;仅在显式安全模式或短时间内重复 GPU 进程崩溃时启用软件渲染,并把故障原因保存在用户数据目录。启动关键路径只创建本地应用壳,认证、代理、同步、更新和遥测在首帧后延迟初始化;开发版可通过 `app:performance` 快照观察 GPU、进程、WebContents、事件循环与 Renderer Long Task 聚合指标。

View File

@@ -11,7 +11,6 @@ import {
FolderKanban, FolderKanban,
Gamepad2, Gamepad2,
LogOut, LogOut,
PanelLeftClose,
Plus, Plus,
RadioTower, RadioTower,
Settings as SettingsIcon, Settings as SettingsIcon,
@@ -91,7 +90,6 @@ export function Sidebar({
}: SidebarProps = {}) { }: SidebarProps = {}) {
const { t } = useTranslation('common'); const { t } = useTranslation('common');
const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const storedSidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth)); const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth));
const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed; const sidebarPinnedCollapsed = !forceExpanded && storedSidebarCollapsed;
const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen; const sidebarCollapsed = sidebarPinnedCollapsed && !sidebarPeekOpen;
@@ -415,17 +413,8 @@ export function Sidebar({
)} )}
> >
<div className="min-w-0 w-full"> <div className="min-w-0 w-full">
{workspaceLayout && <div className="no-drag flex h-10 min-w-0 items-center gap-1" data-testid="classroom-sidebar-navigation"> {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 /> <ModuleSwitcher sidebarCollapsed={sidebarCollapsed} toolbar iconOnly />
{!sidebarPinnedCollapsed && ['darwin', 'win32'].includes(window.electron?.platform ?? '') && <button
type="button"
data-testid="titlebar-sidebar-toggle"
aria-label="折叠侧栏"
title="折叠侧栏"
aria-expanded="true"
onClick={() => setSidebarCollapsed(true)}
className="no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground"
><PanelLeftClose className="h-4 w-4" /></button>}
</div>} </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"> {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="classroom-brand-mark"><img src={classroomMark} alt="" className="h-7 w-7" /></span>

View File

@@ -15,7 +15,8 @@ import { useSettingsStore } from '@/stores/settings';
import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout';
import logoWordmarkSource from '@/assets/makelore-wordmark-source.png'; import logoWordmarkSource from '@/assets/makelore-wordmark-source.png';
import type { SidebarPeekSource } from './sidebar-peek'; import type { SidebarPeekSource } from './sidebar-peek';
import { TITLEBAR_LOGO_WIDTH, WINDOWS_TITLEBAR_CONTROLS_WIDTH } from './titlebar-metrics'; import { ModuleSwitcher } from './ModuleSwitcher';
import { getClassroomTitlebarWidth, TITLEBAR_LOGO_WIDTH, WINDOWS_TITLEBAR_CONTROLS_WIDTH } from './titlebar-metrics';
type SidebarPeekChange = (open: boolean, source: SidebarPeekSource) => void; type SidebarPeekChange = (open: boolean, source: SidebarPeekSource) => void;
@@ -69,6 +70,7 @@ function ProductTitleBar({
}) { }) {
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth)); const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth));
const classroomTitlebarWidth = getClassroomTitlebarWidth(nativeTrafficLights ? 'darwin' : 'win32', sidebarCollapsed, sidebarWidth);
const activeProject = useCodingWorkspaceStore((state) => state.activeProject); const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const toolbarButtonClass = 'no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground'; const toolbarButtonClass = 'no-drag motion-press flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-surface-subtle hover:text-foreground';
const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed); const setSidebarCollapsed = useSettingsStore((state) => state.setSidebarCollapsed);
@@ -94,13 +96,13 @@ function ProductTitleBar({
className={cn( className={cn(
toolbarButtonClass, toolbarButtonClass,
'pointer-events-auto absolute top-1 z-[401]', 'pointer-events-auto absolute top-1 z-[401]',
nativeTrafficLights ? 'left-[88px]' : 'left-2', workspaceLayout ? 'right-2' : nativeTrafficLights ? 'left-[88px]' : 'left-2',
)} )}
onClick={toggleSidebar} onClick={toggleSidebar}
onPointerEnter={() => handleSidebarPeekEnter('toggle')} onPointerEnter={() => handleSidebarPeekEnter('toggle')}
onPointerLeave={() => handleSidebarPeekLeave('toggle')} onPointerLeave={() => handleSidebarPeekLeave('toggle')}
onFocus={() => handleSidebarPeekEnter('focus')} onFocus={!workspaceLayout ? () => handleSidebarPeekEnter('focus') : undefined}
onBlur={() => handleSidebarPeekLeave('focus')} onBlur={!workspaceLayout ? () => handleSidebarPeekLeave('focus') : undefined}
> >
{sidebarCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />} {sidebarCollapsed ? <PanelLeft className="h-4 w-4" /> : <PanelLeftClose className="h-4 w-4" />}
</button> </button>
@@ -110,10 +112,23 @@ function ProductTitleBar({
data-testid="titlebar-sidebar-toggle-layer" data-testid="titlebar-sidebar-toggle-layer"
className={cn( className={cn(
'no-drag pointer-events-auto fixed left-0 top-0 z-[400] h-10', '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 ? <div
data-testid="classroom-titlebar-navigation"
className={cn('flex h-full items-center gap-2 pr-12', nativeTrafficLights ? 'pl-[88px]' : 'pl-2')}
>
<ModuleSwitcher sidebarCollapsed={false} toolbar iconOnly />
{sidebarToggle}
</div> : sidebarToggle}
</div> </div>
); );
const titlebarLogo = ( const titlebarLogo = (
@@ -155,16 +170,16 @@ function ProductTitleBar({
<> <>
<div <div
data-testid="titlebar-sidebar-surface" data-testid="titlebar-sidebar-surface"
style={workspaceLayout && !sidebarTitlebarHidden ? { width: sidebarWidth, minWidth: sidebarWidth, flexBasis: sidebarWidth } : undefined} style={workspaceLayout ? { width: classroomTitlebarWidth, minWidth: classroomTitlebarWidth, flexBasis: classroomTitlebarWidth } : undefined}
className={cn( className={cn(
'flex h-full shrink-0 items-center gap-1 px-2', 'flex h-full shrink-0 items-center gap-1 px-2',
sidebarTitlebarHidden workspaceLayout
? ''
: sidebarTitlebarHidden
? nativeTrafficLights ? nativeTrafficLights
? 'w-[132px]' ? 'w-[132px]'
: 'w-[52px]' : 'w-[52px]'
: workspaceLayout : 'w-64',
? ''
: 'w-64',
overlay overlay
? 'pointer-events-none border-r-0 bg-transparent' ? 'pointer-events-none border-r-0 bg-transparent'
: sidebarTitlebarHidden : sidebarTitlebarHidden
@@ -178,7 +193,7 @@ function ProductTitleBar({
onPointerEnter={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekEnter('titlebar') : undefined} onPointerEnter={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekEnter('titlebar') : undefined}
onPointerLeave={sidebarPreviewTitlebarVisible ? () => handleSidebarPeekLeave('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} ) : null}
{integrated ? ( {integrated ? (

View File

@@ -1,4 +1,13 @@
import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout';
export const TITLEBAR_LOGO_WIDTH = 96; export const TITLEBAR_LOGO_WIDTH = 96;
// Three 44px buttons plus the control container's 8px padding on each side. // Three 44px buttons plus the control container's 8px padding on each side.
export const WINDOWS_TITLEBAR_CONTROLS_WIDTH = 148; 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));
}

View File

@@ -14,7 +14,7 @@ import { CodingConversationRenameDialog } from './CodingConversationRenameDialog
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
import { normalizeClassroomSidebarWidth } from '@/lib/classroom-layout'; 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 { ConversationSnapshot } from '@/types/coding-conversation';
import type { CodingConversationMetadata } from '@/types/coding-project'; 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" className="pointer-events-none fixed right-0 top-0 z-[80] h-10"
data-testid="coding-conversation-header-titlebar" data-testid="coding-conversation-header-titlebar"
style={{ 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. // Keep both actions and their hit area outside the product chrome.
right: platform === 'win32' ? WINDOWS_TITLEBAR_CONTROLS_WIDTH : 0, right: platform === 'win32' ? WINDOWS_TITLEBAR_CONTROLS_WIDTH : 0,
}} }}

View File

@@ -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="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"] { 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"] .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"] [data-testid="classroom-sidebar-navigation"] { align-items: center; }
[data-testid="sidebar"][data-classroom="true"] .classroom-brand { gap: 10px; padding: 0 2px 8px; } [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: 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"] { 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: #dfeacf; border-color: #b7c6a4; } [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; } [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-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; } .classroom-brand-caption { display: block; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; color: #64758a; }

View File

@@ -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'); await expect(page.getByTestId('sidebar-course-project-project-storybook')).toHaveAttribute('aria-current', 'page');
const sidebar = page.getByTestId('sidebar'); 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).toBeVisible();
await expect(moduleReturn).toHaveText(''); await expect(moduleReturn).toHaveText('');
await expect(moduleReturn).toHaveAttribute('aria-label', '返回首页'); await expect(moduleReturn).toHaveAttribute('aria-label', '返回首页');
await expect(moduleReturn).toHaveAttribute('title', '返回首页'); 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 brandBox = await sidebar.locator('.classroom-brand').boundingBox();
const returnBox = await moduleReturn.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(); const sidebarBox = await sidebar.boundingBox();
expect(brandBox).not.toBeNull(); expect(brandBox).not.toBeNull();
expect(returnBox).not.toBeNull(); expect(returnBox).not.toBeNull();
expect(collapseBox).not.toBeNull(); expect(collapseBox).not.toBeNull();
expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(brandBox!.y); expect(returnBox!.y + returnBox!.height).toBeLessThanOrEqual(brandBox!.y);
expect(returnBox!.y - sidebarBox!.y).toBe(0); const headerBox = await page.getByTestId('coding-conversation-header').boundingBox();
expect(brandBox!.y - sidebarBox!.y).toBe(40); 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(returnBox!.width).toBe(32);
expect(Math.abs(returnBox!.y + returnBox!.height / 2 - collapseBox!.y - collapseBox!.height / 2)).toBeLessThan(1); expect(Math.abs(returnBox!.y + returnBox!.height / 2 - collapseBox!.y - collapseBox!.height / 2)).toBeLessThan(1);
expect(returnBox!.x + returnBox!.width).toBeLessThanOrEqual(collapseBox!.x); 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).toBeGreaterThanOrEqual(brandBox!.y + brandBox!.height);
expect(communityBox!.y - brandBox!.y - brandBox!.height).toBe(10); expect(communityBox!.y - brandBox!.y - brandBox!.height).toBe(10);
expect(createBox!.y - communityBox!.y - communityBox!.height).toBe(10); expect(createBox!.y - communityBox!.y - communityBox!.height).toBe(10);
expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(56); expect(createBox!.y - brandBox!.y - brandBox!.height).toBe(61);
await expect(community).toHaveCSS('background-color', 'rgb(232, 240, 223)'); 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).toBeGreaterThanOrEqual(createBox!.y + createBox!.height);
expect(headingBox!.y - createBox!.y - createBox!.height).toBeLessThanOrEqual(14); expect(headingBox!.y - createBox!.y - createBox!.height).toBeLessThanOrEqual(14);
await page.screenshot({ path: test.info().outputPath('chat-code-return-entry.png') }); 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 sidebarResizer.press('Home');
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '128'); await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '128');
const narrowReturn = await moduleReturn.boundingBox(); 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); expect(narrowReturn!.x + narrowReturn!.width).toBeLessThanOrEqual(narrowToggle!.x);
await expect(moduleReturn).toBeVisible(); 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(); await expect(community).toBeVisible();
expect(await community.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); expect(await community.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true);
await page.screenshot({ path: test.info().outputPath('chat-narrow-navigation-row.png') }); 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 sidebarToggle.click();
await page.getByTestId('coding-chat-panel').click({ position: { x: 500, y: 200 } }); await page.getByTestId('coding-chat-panel').click({ position: { x: 500, y: 200 } });
await expect(sidebar).toHaveAttribute('aria-hidden', 'true'); await expect(sidebar).toHaveAttribute('aria-hidden', 'true');
const collapsedToggleBox = await sidebarToggle.boundingBox();
await expect(moduleReturn).toBeVisible();
await sidebarToggle.hover(); await sidebarToggle.hover();
await expect(sidebar).toHaveAttribute('aria-hidden', 'false'); await expect(sidebar).toHaveAttribute('aria-hidden', 'false');
expect(await sidebarToggle.boundingBox()).toEqual(collapsedToggleBox);
await expect(moduleReturn).toBeVisible(); await expect(moduleReturn).toBeVisible();
await page.screenshot({ path: test.info().outputPath('chat-collapsed-hover-code-return.png') }); await page.screenshot({ path: test.info().outputPath('chat-collapsed-hover-code-return.png') });
await moduleReturn.click(); await moduleReturn.click();

View File

@@ -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')).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(); expect(screen.queryByTestId('coding-conversation-sidebar')).not.toBeInTheDocument();
window.electron.platform = originalPlatform; window.electron.platform = originalPlatform;

View File

@@ -1,8 +1,19 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; 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 { TitleBar } from '@/components/layout/TitleBar';
import { CodingConversationHeader } from '@/pages/Chat/CodingConversationHeader';
import { useSettingsStore } from '@/stores/settings'; import { useSettingsStore } from '@/stores/settings';
function render(ui: ReactElement) {
return renderComponent(ui, { wrapper: ({ children }) => <MemoryRouter initialEntries={['/chat']}>{children}</MemoryRouter> });
}
function LocationPath() {
return <span data-testid="location-path">{useLocation().pathname}</span>;
}
const invokeIpcMock = vi.hoisted(() => vi.fn()); const invokeIpcMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/api-client', () => ({ vi.mock('@/lib/api-client', () => ({
@@ -91,7 +102,10 @@ describe('TitleBar platform behavior', () => {
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument(); expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
expect(screen.queryByRole('img', { name: 'Makelore logo' })).not.toBeInTheDocument(); expect(screen.queryByRole('img', { name: 'Makelore logo' })).not.toBeInTheDocument();
expect(screen.queryByTestId('titlebar-project-context')).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', () => { 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 }); useSettingsStore.setState({ sidebarCollapsed: true });
const { rerender } = render(<TitleBar integrated workspaceLayout sidebarPeekOpen={false} />); const { rerender } = render(<TitleBar integrated workspaceLayout sidebarPeekOpen={false} />);
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-conversation-surface')).not.toBeInTheDocument();
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument(); expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: '展开侧栏' })).toHaveAttribute('aria-expanded', 'false'); expect(screen.getByRole('button', { name: '展开侧栏' })).toHaveAttribute('aria-expanded', 'false');
rerender(<TitleBar integrated workspaceLayout sidebarPeekOpen />); rerender(<TitleBar integrated workspaceLayout sidebarPeekOpen />);
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-conversation-surface')).not.toBeInTheDocument();
expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument(); expect(screen.queryByTestId('titlebar-logo')).not.toBeInTheDocument();
const pinButton = screen.getByRole('button', { name: '固定侧栏' }); const pinButton = screen.getByRole('button', { name: '固定侧栏' });
@@ -195,7 +209,67 @@ describe('TitleBar platform behavior', () => {
fireEvent.click(pinButton); fireEvent.click(pinButton);
expect(useSettingsStore.getState().sidebarCollapsed).toBe(false); expect(useSettingsStore.getState().sidebarCollapsed).toBe(false);
expect(screen.getByTestId('titlebar-sidebar-surface')).toHaveStyle({ width: '171px', minWidth: '171px', flexBasis: '171px' }); 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(<>
<TitleBar integrated workspaceLayout />
<CodingConversationHeader conversation={null} snapshot={null} onRename={vi.fn()} onAbort={vi.fn()} onRecover={vi.fn()} />
<LocationPath />
</>);
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(<>
<TitleBar integrated workspaceLayout sidebarPeekOpen onSidebarPeekChange={onSidebarPeekChange} />
<button type="button">Outside navigation</button>
</>);
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', () => { it('extends the ordinary titlebar sidebar surface during a hover preview', () => {