Align fullscreen return button with project actions

This commit is contained in:
鲨鱼辣椒
2026-09-30 00:58:46 +08:00
parent 80b0cb99a8
commit 343f88ce59
9 changed files with 203 additions and 7 deletions

View File

@@ -8,7 +8,7 @@
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20260929-empty-work-merge-39c633d0
- Base commit: a77ddbd6813bb71d490544edfef6e1f35240070f
- Owner: codex
- Status: Ready for Integration
- Status: Planning
## Scope
@@ -139,3 +139,13 @@
- Temporary app updated in place from the current composite, preserving all 13 prior overlays and adding 883ae1d. Final source: `sidebar-rails-33d87a81/source`; live/next JS `index-pmc1zOQa.js`, common CSS `index-D1Sa4hBY.css`, route CSS `index-CEKmXNFU.css`. Launch/guidance/build metadata finalized consistently. Main PID 32583/profile/native visibility and focus are unchanged; no formal installation or main/remote update.
- Live checks confirm return and collapse centers align with the project/conversation header, the 46px brand aligns with the 46px tab row, native traffic lights and breadcrumb hit areas remain clear, and Works Square/New Project have matching height, border, radius, padding, typography and shadow, with the requested pale fill and equal 10px gaps. An unfocused-window color transition was paused at time zero; completed that button's six pending visual transitions before comparing final computed styles. No navigation or data action was triggered by this check.
- Account, current conversation/messages, all 14 consultation drafts, closed teacher panel/content and Operation Chat selection match the pre-refresh snapshot; all 34 recorded data hashes unchanged, zero alerts. Released refresh lease, restored background throttling and closed the task-opened inspector. Temporary app ready; working branch retains the source for later integration.
## Follow-up: Left-Align Return In Native Fullscreen
- User asks to align the return button's left edge with New Project. Live read-only inspection confirms the current macOS trial is in native fullscreen, while DOM fullscreen and display-mode media queries are both false; the existing 88px native-traffic-light reservation is unnecessary in that state. Keep current vertical alignment and collapse placement; fullscreen and Windows return inset is 12px, while macOS windowed mode retains the native safe inset.
- Project Context Loaded / Planning Gate: Passed. Exact same-task feature identity and official resume verified. Active task, entry and all peer scopes reread; previously loaded memory/architecture/domain/context unchanged from 80b0cb9, no new peer scope conflict. Current trial source sidebar-rails-33d87a81/source is consistent with served and has all 14 overlays. Preserve native window behavior and Main ownership: add only a read-only fullscreen query/event bridge, subscribe before querying with race protection, then adapt the existing titlebar. No screen-size heuristic or native-button hiding.
- Plan: small reusable Main fullscreen IPC binding plus Preload allowlists, renderer subscription adapter and conditional 12px titlebar padding, update focused fullscreen/reopened-window/race tests and existing E2E with simulated native events in the isolated test process. For the existing trial, install only this read-only Main binding and allowlist addition alongside the new Renderer, preserve the current Main/profile/window state, and record the startup overlay for next launch. No main merge/push or formal-app change.
- Implemented native fullscreen query/event binding with window-reopen support, scoped preload allowlists, a race-safe Renderer observer and 12px fullscreen/Windows return inset. macOS windowed traffic-light space, rail widths, collapse coordinates and brand position are unchanged. The observer also keeps the safe inset when an older preload rejects the event. 27 focused unit tests, typecheck, scoped lint and production build passed; the existing Electron navigation E2E passed including enter/leave native events, exact return/New Project left alignment and unchanged brand/collapse boxes. Fullscreen screenshot inspected. Final candidate includes the fallback fix and passes typecheck/lint/build.
- Temporary composite preserves all previous overlays and differs in exactly five product files. Its older Main adapter retains single-window semantics; the live update will install only the standalone read-only fullscreen module and two preload allowlist additions, not replace the Main bundle. Publication pending.

View File

@@ -6,6 +6,7 @@ import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron';
import { registerHostApiProxyHandlers } from './ipc/host-api-proxy';
import { registerTranscriptExportHandler } from './ipc/transcript-export';
import { registerConversationLinkContextMenuHandler } from './ipc/conversation-link-context-menu';
import { registerWindowFullscreenHandlers } from './ipc/window-fullscreen';
import { applyProxySettings } from './proxy';
import { syncLaunchAtStartupSettingFromStore } from './launch-at-startup';
import { getAllSettings, getSetting, resetSettings, setSetting, type AppSettings } from '../utils/store';
@@ -254,6 +255,7 @@ export function registerIpcHandlers(
if (!window || window.isDestroyed()) throw new Error('Main window is unavailable');
return window;
};
const bindWindowFullscreen = registerWindowFullscreenHandlers(requireRequestWindow);
registerHostApiProxyHandlers(hostApiContext);
registerTranscriptExportHandler(getMainWindow);
registerConversationLinkContextMenuHandler(getMainWindow);
@@ -272,7 +274,10 @@ export function registerIpcHandlers(
return leases;
};
const bindWindow = (window: BrowserWindow): void => {
if (!window.isDestroyed() && !window.webContents.isDestroyed()) bindRenderer(window.webContents);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) {
bindRenderer(window.webContents);
bindWindowFullscreen(window);
}
};
const initialWindow = getMainWindow();
if (initialWindow) bindWindow(initialWindow);

View File

@@ -0,0 +1,20 @@
import { ipcMain, type BrowserWindow } from 'electron';
export function registerWindowFullscreenHandlers(
requireWindow: (event: Electron.IpcMainInvokeEvent) => BrowserWindow,
): (window: BrowserWindow) => void {
ipcMain.handle('window:isFullScreen', (event) => requireWindow(event).isFullScreen());
const boundWindows = new WeakSet<BrowserWindow>();
return (window: BrowserWindow): void => {
if (window.isDestroyed() || window.webContents.isDestroyed() || boundWindows.has(window)) return;
boundWindows.add(window);
const notify = (fullscreen: boolean): void => {
if (window.isDestroyed() || window.webContents.isDestroyed()) return;
window.webContents.send('window:fullscreen-changed', fullscreen);
};
window.on('enter-full-screen', () => notify(true));
window.on('leave-full-screen', () => notify(false));
};
}

View File

@@ -33,6 +33,7 @@ const validInvokeChannels = [
'window:maximize',
'window:close',
'window:isMaximized',
'window:isFullScreen',
'settings:get',
'settings:set',
'settings:setMany',
@@ -64,6 +65,7 @@ const validInvokeChannels = [
];
const validEventChannels = [
'window:fullscreen-changed',
'navigate',
'cloud-agent-link',
'update:status-changed',

View File

@@ -9,6 +9,7 @@ import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { Copy, FolderOpen, Minus, PanelLeft, PanelLeftClose, Square, X } from 'lucide-react';
import { invokeIpc } from '@/lib/api-client';
import { observeNativeFullscreen } from '@/lib/window-fullscreen';
import { cn } from '@/lib/utils';
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
import { useSettingsStore } from '@/stores/settings';
@@ -68,6 +69,10 @@ function ProductTitleBar({
sidebarPeekOpen?: boolean;
onSidebarPeekChange?: SidebarPeekChange;
}) {
const [nativeFullscreen, setNativeFullscreen] = useState(false);
useEffect(() => {
if (nativeTrafficLights) return observeNativeFullscreen(setNativeFullscreen);
}, [nativeTrafficLights]);
const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed);
const sidebarWidth = useSettingsStore((state) => normalizeClassroomSidebarWidth(state.classroomSidebarWidth));
const classroomTitlebarWidth = getClassroomTitlebarWidth(nativeTrafficLights ? 'darwin' : 'win32', sidebarCollapsed, sidebarWidth);
@@ -124,7 +129,7 @@ function ProductTitleBar({
>
{workspaceLayout ? <div
data-testid="classroom-titlebar-navigation"
className={cn('flex h-full items-center gap-2 pr-12', nativeTrafficLights ? 'pl-[88px]' : 'pl-2')}
className={cn('flex h-full items-center gap-2 pr-12', nativeTrafficLights && !nativeFullscreen ? 'pl-[88px]' : 'pl-3')}
>
<ModuleSwitcher sidebarCollapsed={false} toolbar iconOnly />
{sidebarToggle}

View File

@@ -0,0 +1,29 @@
import { invokeIpc } from './api-client';
/** Observe native window fullscreen, which is separate from DOM fullscreen. */
export function observeNativeFullscreen(onChange: (fullscreen: boolean) => void): () => void {
let active = true;
let revision = 0;
let unsubscribe: (() => void) | void;
try {
unsubscribe = window.electron.ipcRenderer.on('window:fullscreen-changed', (fullscreen) => {
if (!active || typeof fullscreen !== 'boolean') return;
revision += 1;
onChange(fullscreen);
});
} catch {
// Older preloads reject unknown channels synchronously. Keep the safe inset.
return () => {};
}
const queryRevision = revision;
void invokeIpc<boolean>('window:isFullScreen').then((fullscreen) => {
// A transition received during the query is more recent than its snapshot.
if (active && revision === queryRevision && typeof fullscreen === 'boolean') onChange(fullscreen);
}).catch(() => {
// Keep the windowed safe inset when an older host lacks the query.
});
return () => {
active = false;
unsubscribe?.();
};
}

View File

@@ -126,6 +126,17 @@ test.describe('Makelore module navigation without setup flow', () => {
expect(headingBox!.y - createBox!.y - createBox!.height).toBeLessThanOrEqual(14);
await page.screenshot({ path: test.info().outputPath('chat-code-return-entry.png') });
// Exercise native state events without switching the test app into a macOS Space.
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('enter-full-screen'));
await expect.poll(async () => (await moduleReturn.boundingBox())!.x).toBe(createBox!.x);
const fullscreenReturn = await moduleReturn.boundingBox();
expect(fullscreenReturn!.y).toBe(returnBox!.y);
expect(await page.getByRole('button', { name: '折叠侧栏' }).boundingBox()).toEqual(collapseBox);
expect(await sidebar.locator('.classroom-brand').boundingBox()).toEqual(brandBox);
await page.screenshot({ path: test.info().outputPath('chat-fullscreen-return-aligned.png') });
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].emit('leave-full-screen'));
await expect.poll(async () => (await moduleReturn.boundingBox())!.x).toBe(returnBox!.x);
const sidebarResizer = sidebar.getByRole('separator', { name: '调整左侧功能栏宽度' });
await sidebarResizer.press('Home');
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '128');

View File

@@ -9,7 +9,7 @@ function createWindow(id: number) {
isDestroyed: vi.fn(() => false),
send: vi.fn(),
});
return {
return Object.assign(new EventEmitter(), {
id,
webContents: contents,
isDestroyed: vi.fn(() => false),
@@ -17,8 +17,9 @@ function createWindow(id: number) {
maximize: vi.fn(),
unmaximize: vi.fn(),
isMaximized: vi.fn(() => false),
isFullScreen: vi.fn(() => false),
close: vi.fn(),
};
});
}
async function setup() {
@@ -104,6 +105,74 @@ describe('IPC after closing and reopening the main window', () => {
expect(ctx.second.webContents.listenerCount('destroyed')).toBe(1);
});
it('queries fullscreen for the requesting window before and after reopening', async () => {
const ctx = await setup();
ctx.first.isFullScreen.mockReturnValue(true);
expect(ctx.invoke('window:isFullScreen', ctx.first)).toBe(true);
expect(ctx.invoke('window:isFullScreen', ctx.second)).toBe(false);
ctx.reopen();
expect(ctx.invoke('window:isFullScreen', ctx.second)).toBe(false);
ctx.second.isFullScreen.mockReturnValue(true);
expect(ctx.invoke('window:isFullScreen', ctx.second)).toBe(true);
expect(() => ctx.invoke('window:isFullScreen', ctx.first)).toThrow('Main window is unavailable');
});
it('notifies each bound renderer once when its window enters or leaves fullscreen', async () => {
const ctx = await setup();
ctx.bindings.bindWindow(ctx.first as unknown as BrowserWindow);
expect(ctx.first.listenerCount('enter-full-screen')).toBe(1);
expect(ctx.first.listenerCount('leave-full-screen')).toBe(1);
ctx.first.emit('enter-full-screen');
ctx.first.emit('leave-full-screen');
expect(ctx.first.webContents.send.mock.calls).toEqual([
['window:fullscreen-changed', true],
['window:fullscreen-changed', false],
]);
expect(ctx.second.webContents.send).not.toHaveBeenCalled();
const registeredCount = ctx.handle.mock.calls.length;
ctx.reopen();
ctx.bindings.bindWindow(ctx.second as unknown as BrowserWindow);
expect(ctx.second.listenerCount('enter-full-screen')).toBe(1);
expect(ctx.second.listenerCount('leave-full-screen')).toBe(1);
ctx.second.emit('enter-full-screen');
ctx.second.emit('leave-full-screen');
expect(ctx.second.webContents.send.mock.calls).toEqual([
['window:fullscreen-changed', true],
['window:fullscreen-changed', false],
]);
expect(ctx.first.webContents.send).toHaveBeenCalledTimes(2);
expect(ctx.handle).toHaveBeenCalledTimes(registeredCount);
});
it('does not send fullscreen events after a window or renderer is destroyed', async () => {
const ctx = await setup();
ctx.reopen();
ctx.first.emit('enter-full-screen');
ctx.first.emit('leave-full-screen');
expect(ctx.first.webContents.send).not.toHaveBeenCalled();
ctx.second.webContents.isDestroyed.mockReturnValue(true);
ctx.second.emit('enter-full-screen');
ctx.second.emit('leave-full-screen');
expect(ctx.second.webContents.send).not.toHaveBeenCalled();
});
it('does not bind fullscreen events to an already destroyed window or renderer', async () => {
const ctx = await setup();
const closed = createWindow(3);
closed.isDestroyed.mockReturnValue(true);
ctx.bindings.bindWindow(closed as unknown as BrowserWindow);
expect(closed.listenerCount('enter-full-screen')).toBe(0);
expect(closed.listenerCount('leave-full-screen')).toBe(0);
ctx.second.webContents.isDestroyed.mockReturnValue(true);
ctx.bindings.bindWindow(ctx.second as unknown as BrowserWindow);
expect(ctx.second.listenerCount('enter-full-screen')).toBe(0);
expect(ctx.second.listenerCount('leave-full-screen')).toBe(0);
});
it('parents dialogs and link menus to the reopened renderer', async () => {
const ctx = await setup();
ctx.reopen();

View File

@@ -24,6 +24,7 @@ describe('TitleBar platform behavior', () => {
beforeEach(() => {
invokeIpcMock.mockReset();
invokeIpcMock.mockResolvedValue(false);
vi.mocked(window.electron.ipcRenderer.on).mockReset();
useSettingsStore.setState({ sidebarCollapsed: false, classroomSidebarWidth: 171 });
});
@@ -84,7 +85,7 @@ describe('TitleBar platform behavior', () => {
expect(container.querySelector('.drag-region')).toBeInTheDocument();
expect(screen.queryByTitle('Minimize')).not.toBeInTheDocument();
expect(invokeIpcMock).not.toHaveBeenCalled();
expect(invokeIpcMock).toHaveBeenCalledWith('window:isFullScreen');
});
it('aligns the classroom title bar with one project sidebar and no duplicate branding', () => {
@@ -108,6 +109,50 @@ describe('TitleBar platform behavior', () => {
expect(screen.getByTestId('titlebar-sidebar-toggle')).toHaveClass('right-2');
});
it('aligns fullscreen return with project buttons and restores native window-control space on exit', async () => {
window.electron.platform = 'darwin';
invokeIpcMock.mockResolvedValue(true);
const unsubscribe = vi.fn();
let notify: (...args: unknown[]) => void = () => {};
vi.mocked(window.electron.ipcRenderer.on).mockImplementation((channel, callback) => {
expect(channel).toBe('window:fullscreen-changed');
notify = callback;
return unsubscribe;
});
const { unmount } = render(<TitleBar integrated workspaceLayout />);
const navigation = screen.getByTestId('classroom-titlebar-navigation');
await waitFor(() => expect(navigation).toHaveClass('pl-3'));
act(() => notify(false));
expect(navigation).toHaveClass('pl-[88px]');
act(() => notify(true));
expect(navigation).toHaveClass('pl-3');
unmount();
expect(unsubscribe).toHaveBeenCalledOnce();
});
it('keeps a native fullscreen transition when an older initial query resolves later', async () => {
window.electron.platform = 'darwin';
let resolveQuery: (value: boolean) => void = () => {};
invokeIpcMock.mockReturnValue(new Promise<boolean>((resolve) => { resolveQuery = resolve; }));
let notify: (...args: unknown[]) => void = () => {};
vi.mocked(window.electron.ipcRenderer.on).mockImplementation((_channel, callback) => {
notify = callback;
return vi.fn();
});
render(<TitleBar integrated workspaceLayout />);
act(() => notify(true));
await act(async () => resolveQuery(false));
expect(screen.getByTestId('classroom-titlebar-navigation')).toHaveClass('pl-3');
});
it('keeps the native safe inset when an older preload rejects fullscreen events', () => {
window.electron.platform = 'darwin';
vi.mocked(window.electron.ipcRenderer.on).mockImplementation(() => { throw new Error('Invalid IPC channel'); });
render(<TitleBar integrated workspaceLayout />);
expect(screen.getByTestId('classroom-titlebar-navigation')).toHaveClass('pl-[88px]');
expect(invokeIpcMock).not.toHaveBeenCalled();
});
it('renders the painting title bar as a transparent overlay with the logo at the window edge', () => {
window.electron.platform = 'darwin';
@@ -214,7 +259,7 @@ describe('TitleBar platform behavior', () => {
it.each([
['darwin', 168, 168, 'pl-[88px]'],
['win32', 128, 88, 'pl-2'],
['win32', 128, 88, 'pl-3'],
] 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 });