mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 08:53:09 +00:00
fix(sytle): windows sidebar and cronjob card front style fix (#1011)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user