fix(sytle): windows sidebar and cronjob card front style fix (#1011)

This commit is contained in:
Haze
2026-05-13 14:30:36 +08:00
committed by GitHub
parent 62c11d2441
commit 42d62f5002
8 changed files with 160 additions and 24 deletions
+7 -2
View File
@@ -10,13 +10,15 @@ import { cn } from '@/lib/utils';
export function MainLayout() {
const platform = window.electron?.platform;
const isMac = platform === 'darwin';
const isWin = platform === 'win32';
return (
<div
data-testid="main-layout"
data-platform={platform}
className={cn(
'flex h-screen overflow-hidden bg-background',
'flex h-screen overflow-hidden',
isWin ? 'bg-surface-sidebar' : 'bg-background',
isMac ? 'flex-row' : 'flex-col',
)}
>
@@ -26,7 +28,10 @@ export function MainLayout() {
<Sidebar />
<main
data-testid="main-content"
className="min-h-0 flex-1 overflow-auto rounded-tl-2xl border-l border-t border-border/60 bg-background p-6"
className={cn(
'min-h-0 flex-1 overflow-auto rounded-tl-2xl border-l border-border/60 bg-background p-6',
!isWin && 'border-t border-border/60',
)}
>
<Outlet />
</main>
+5 -5
View File
@@ -305,7 +305,7 @@ export function Sidebar() {
</div>
{/* Navigation */}
<nav className="flex flex-col gap-0 px-2">
<nav className="flex flex-col gap-1 px-2">
<button
type="button"
data-testid="sidebar-new-chat"
@@ -315,7 +315,7 @@ export function Sidebar() {
navigate('/');
}}
className={cn(
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'sidebar-nav-text flex items-center gap-2 rounded-lg px-2.5 py-2 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed && 'justify-center px-0',
)}
@@ -337,7 +337,7 @@ export function Sidebar() {
{/* Session list — below Settings, only when expanded */}
{!sidebarCollapsed && sessions.length > 0 && (
<div className="mt-4 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2 space-y-0.5">
<div className="mt-4 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2 space-y-1">
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
<div key={bucket.key} data-testid={`session-bucket-${bucket.key}`} className="pt-2">
@@ -400,7 +400,7 @@ export function Sidebar() {
)}
{/* Footer */}
<div className="p-2 mt-auto">
<div className="mt-auto flex flex-col gap-1 p-2">
<NavLink
to="/settings"
data-testid="sidebar-nav-settings"
@@ -426,7 +426,7 @@ export function Sidebar() {
data-testid="sidebar-open-dev-console"
variant="ghost"
className={cn(
'sidebar-nav-text mt-0.5 flex h-auto w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'sidebar-nav-text flex h-auto w-full items-center gap-2 rounded-lg px-2.5 py-1.5 transition-colors',
'hover:bg-black/5 dark:hover:bg-white/5 text-foreground/80',
sidebarCollapsed ? 'justify-center px-0' : 'justify-start'
)}
+3 -3
View File
@@ -1,7 +1,8 @@
/**
* TitleBar Component
* macOS: empty drag region (native traffic lights handled by hiddenInset).
* Windows: drag region with custom minimize/maximize/close controls.
* Windows: drag region with custom minimize/maximize/close controls; uses
* `bg-surface-sidebar` so the frameless strip matches the sidebar rail.
* Linux: use native window chrome (no custom title bar).
*/
import { useState, useEffect } from 'react';
@@ -53,9 +54,8 @@ function WindowsTitleBar() {
return (
<div
data-testid="windows-titlebar"
className="drag-region flex h-10 shrink-0 items-center justify-end bg-background"
className="drag-region flex h-10 shrink-0 items-center justify-end bg-surface-sidebar"
>
{/* Right: Window Controls */}
<div className="no-drag flex h-full">
<button
+13 -12
View File
@@ -740,17 +740,18 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT
return (
<div
data-testid={`cron-job-card-${job.id}`}
className="group flex flex-col p-5 rounded-2xl bg-transparent border border-transparent hover:bg-black/5 dark:hover:bg-white/5 transition-all relative overflow-hidden cursor-pointer"
onClick={onEdit}
>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-4">
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-4 min-w-0 flex-1">
<div className="h-[46px] w-[46px] shrink-0 flex items-center justify-center text-foreground bg-black/5 dark:bg-white/5 border border-black/5 dark:border-white/10 rounded-full shadow-sm group-hover:scale-105 transition-transform">
<Clock className={cn("h-5 w-5", job.enabled ? "text-foreground" : "text-muted-foreground")} />
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="text-base font-semibold text-foreground truncate">{job.name}</h3>
<div className="flex flex-col min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 min-w-0">
<h3 data-testid={`cron-job-card-title-${job.id}`} className="text-base font-semibold text-foreground truncate min-w-0">{job.name}</h3>
<div
className={cn(
"w-2 h-2 rounded-full shrink-0",
@@ -759,14 +760,14 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT
title={job.enabled ? t('stats.active') : t('stats.paused')}
/>
</div>
<p className="text-meta text-muted-foreground flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5" />
{parseCronSchedule(job.schedule, t)}
<p className="text-meta text-muted-foreground flex items-center gap-1.5 min-w-0">
<Timer className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{parseCronSchedule(job.schedule, t)}</span>
</p>
</div>
</div>
<div className="flex items-center gap-2" onClick={e => e.stopPropagation()}>
<div data-testid={`cron-job-card-switch-${job.id}`} className="flex items-center gap-2 shrink-0" onClick={e => e.stopPropagation()}>
<Switch
checked={job.enabled}
onCheckedChange={onToggle}
@@ -774,10 +775,10 @@ function CronJobCard({ job, deliveryAccountName, onToggle, onEdit, onDelete, onT
</div>
</div>
<div className="flex-1 flex flex-col justify-end mt-2 pl-[62px]">
<div className="flex items-start gap-2 mb-3">
<div className="flex-1 flex flex-col justify-end mt-2 pl-[62px] min-w-0">
<div className="flex items-start gap-2 mb-3 min-w-0">
<MessageSquare className="h-3.5 w-3.5 mt-0.5 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground line-clamp-2 leading-[1.5]">
<p className="text-sm text-muted-foreground line-clamp-2 leading-[1.5] min-w-0 flex-1 break-all">
{job.message}
</p>
</div>
+102
View File
@@ -0,0 +1,102 @@
import { completeSetup, expect, installIpcMocks, test } from './fixtures/electron';
function stableStringify(value: unknown): string {
if (value == null || typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map((item) => stableStringify(item)).join(',')}]`;
const entries = Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => `${JSON.stringify(key)}:${stableStringify(entryValue)}`);
return `{${entries.join(',')}}`;
}
const cronJobs = [
{
id: 'job-long-title',
name: 'Memory Dreaming Promotion With An Unusually Long Title That Should Trigger Truncation',
message: '__openclaw_memory_core_short_term_promotion_pipeline__step_overflow_check__',
schedule: { kind: 'cron', expr: '0 3 * * *' },
enabled: true,
createdAt: '2026-04-30T03:00:00.000Z',
updatedAt: '2026-04-30T03:00:00.000Z',
agentId: 'main',
},
{
id: 'job-short-title',
name: '喝水',
message: '提醒我喝水',
schedule: { kind: 'cron', expr: '*/5 * * * *' },
enabled: false,
createdAt: '2026-04-30T03:00:00.000Z',
updatedAt: '2026-04-30T03:00:00.000Z',
agentId: 'main',
},
];
test.describe('Cron job card layout', () => {
test('keeps the toggle switch fully inside the card when the title is very long', async ({ electronApp, page }) => {
await installIpcMocks(electronApp, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
gatewayRpc: {},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345, gatewayReady: true },
},
},
[stableStringify(['/api/cron/jobs', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: cronJobs,
},
},
[stableStringify(['/api/channels/accounts', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { success: true, channels: [] },
},
},
},
});
await completeSetup(page);
await page.getByTestId('sidebar-nav-cron').click();
const card = page.getByTestId('cron-job-card-job-long-title');
const switchWrapper = page.getByTestId('cron-job-card-switch-job-long-title');
const title = page.getByTestId('cron-job-card-title-job-long-title');
await expect(card).toBeVisible();
await expect(switchWrapper).toBeVisible();
await expect(title).toBeVisible();
const cardBox = await card.boundingBox();
const switchBox = await switchWrapper.boundingBox();
const titleBox = await title.boundingBox();
expect(cardBox, 'card bounding box should be available').not.toBeNull();
expect(switchBox, 'switch bounding box should be available').not.toBeNull();
expect(titleBox, 'title bounding box should be available').not.toBeNull();
if (!cardBox || !switchBox || !titleBox) return;
// The switch must stay fully inside the card horizontally and not be
// partially clipped by the card boundary (this is the regression we are
// guarding against — long titles previously pushed the switch off-card).
const cardRight = cardBox.x + cardBox.width;
const switchRight = switchBox.x + switchBox.width;
expect(switchBox.x).toBeGreaterThanOrEqual(cardBox.x);
expect(switchRight).toBeLessThanOrEqual(cardRight + 0.5);
// The title must shrink before the switch — its right edge should not
// overlap the switch container.
const titleRight = titleBox.x + titleBox.width;
expect(titleRight).toBeLessThanOrEqual(switchBox.x + 0.5);
});
});
+23
View File
@@ -0,0 +1,23 @@
import { expect, test } from './fixtures/electron';
test.describe('Windows frameless chrome', () => {
test.skip(process.platform !== 'win32', 'Windows custom title bar only');
test('uses sidebar-toned shell and no top border on the main panel', async ({ page }) => {
await expect(page.getByTestId('setup-page')).toBeVisible();
await page.getByTestId('setup-skip-button').click();
await expect(page.getByTestId('main-layout')).toBeVisible();
await expect(page.getByTestId('main-layout')).toHaveAttribute('data-platform', 'win32');
const shell = page.getByTestId('main-layout');
await expect(shell).toHaveClass(/bg-surface-sidebar/);
const titleBar = page.getByTestId('windows-titlebar');
await expect(titleBar).toBeVisible();
await expect(titleBar).toHaveClass(/bg-surface-sidebar/);
const main = page.getByTestId('main-content');
await expect(main).not.toHaveClass(/border-t/);
});
});
+4 -1
View File
@@ -24,6 +24,9 @@ describe('MainLayout platform layout', () => {
render(<MainLayout />);
expect(screen.getByTestId('main-layout')).toHaveClass('flex-col');
const layout = screen.getByTestId('main-layout');
expect(layout).toHaveClass('flex-col');
expect(layout).toHaveClass('bg-surface-sidebar');
expect(screen.getByTestId('main-content')).not.toHaveClass('border-t');
});
});
+3 -1
View File
@@ -32,7 +32,9 @@ describe('TitleBar platform behavior', () => {
expect(screen.getByTitle('Minimize')).toBeInTheDocument();
expect(screen.getByTitle('Maximize')).toBeInTheDocument();
expect(screen.getByTitle('Close')).toBeInTheDocument();
expect(screen.getByTestId('windows-titlebar')).not.toHaveClass('border-b');
const bar = screen.getByTestId('windows-titlebar');
expect(bar).toHaveClass('bg-surface-sidebar');
expect(bar).not.toHaveClass('border-b');
await waitFor(() => {
expect(invokeIpcMock).toHaveBeenCalledWith('window:isMaximized');