Align Code navigation and branding with workspace header rows
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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 聚合指标。
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user