chore(front): optimize front issue (#975)

This commit is contained in:
Haze
2026-05-06 12:03:51 +08:00
committed by GitHub
parent 5c2e689cb4
commit 5bc994ada7
6 changed files with 234 additions and 33 deletions

View File

@@ -26,6 +26,7 @@ import { useSettingsStore } from '@/stores/settings';
import { useChatStore } from '@/stores/chat';
import { useGatewayStore } from '@/stores/gateway';
import { useAgentsStore } from '@/stores/agents';
import { getSessionActivityMs, getSessionBucket, type SessionBucketKey } from './session-buckets';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -33,14 +34,6 @@ import { hostApiFetch } from '@/lib/host-api';
import { useTranslation } from 'react-i18next';
import logoSvg from '@/assets/logo.svg';
type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
interface NavItemProps {
to: string;
icon: React.ReactNode;
@@ -89,23 +82,6 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItem
);
}
function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
const INITIAL_NOW_MS = Date.now();
function getAgentIdFromSessionKey(sessionKey: string): string {
@@ -210,10 +186,13 @@ export function Sidebar() {
(typeof sessionBuckets)[number]
>;
for (const session of [...sessions].sort((a, b) =>
(sessionLastActivity[b.key] ?? 0) - (sessionLastActivity[a.key] ?? 0)
)) {
const bucketKey = getSessionBucket(sessionLastActivity[session.key] ?? 0, nowMs);
for (const { session, activityMs } of sessions
.map((session) => ({
session,
activityMs: getSessionActivityMs(session, sessionLastActivity),
}))
.sort((a, b) => b.activityMs - a.activityMs)) {
const bucketKey = getSessionBucket(activityMs, nowMs);
sessionBucketMap[bucketKey].sessions.push(session);
}
@@ -308,7 +287,7 @@ export function Sidebar() {
<div className="mt-4 flex-1 overflow-y-auto overflow-x-hidden px-2 pb-2 space-y-0.5">
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
<div key={bucket.key} className="pt-2">
<div key={bucket.key} data-testid={`session-bucket-${bucket.key}`} className="pt-2">
<div className="px-2.5 pb-1 text-tiny font-medium text-muted-foreground/60 tracking-tight">
{bucket.label}
</div>

View File

@@ -0,0 +1,48 @@
import type { ChatSession } from '@/stores/chat';
export type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
export function getSessionBucket(activityMs: number, nowMs: number): SessionBucketKey {
if (!activityMs || activityMs <= 0) return 'older';
const now = new Date(nowMs);
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000;
if (activityMs >= startOfToday) return 'today';
if (activityMs >= startOfYesterday) return 'yesterday';
const daysAgo = (startOfToday - activityMs) / (24 * 60 * 60 * 1000);
if (daysAgo <= 7) return 'withinWeek';
if (daysAgo <= 14) return 'withinTwoWeeks';
if (daysAgo <= 30) return 'withinMonth';
return 'older';
}
function getSessionCreatedAtMsFromKey(sessionKey: string): number | undefined {
const match = sessionKey.match(/(?:^|:)session-(\d{11,})(?=$|:)/);
if (!match) return undefined;
const createdAtMs = Number(match[1]);
return Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : undefined;
}
export function getSessionActivityMs(
session: ChatSession,
sessionLastActivity: Record<string, number>,
): number {
const lastActivityMs = sessionLastActivity[session.key];
if (Number.isFinite(lastActivityMs) && lastActivityMs > 0) return lastActivityMs;
if (typeof session.updatedAt === 'number' && Number.isFinite(session.updatedAt) && session.updatedAt > 0) {
return session.updatedAt;
}
return getSessionCreatedAtMsFromKey(session.key) ?? 0;
}

View File

@@ -123,7 +123,16 @@
}
}
/* Custom scrollbar */
/* Custom scrollbar: keep scrollbars visually quiet until the user hovers a scroll area. */
* {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
}
*:hover {
scrollbar-color: hsl(var(--border)) transparent;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
@@ -134,10 +143,14 @@
}
::-webkit-scrollbar-thumb {
@apply bg-border rounded-full;
@apply bg-transparent rounded-full;
}
::-webkit-scrollbar-thumb:hover {
*:hover::-webkit-scrollbar-thumb {
@apply bg-border;
}
*:hover::-webkit-scrollbar-thumb:hover {
@apply bg-muted-foreground/30;
}

View File

@@ -0,0 +1,85 @@
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
const MAIN_SESSION_KEY = 'agent:main:main';
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(',')}}`;
}
test.describe('ClawX chat session date grouping', () => {
test('new chat appears in the Today session bucket', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const oldTimestampMs = Date.now() - 35 * 24 * 60 * 60 * 1000;
const seededHistory = [
{ role: 'user', content: 'Existing conversation', timestamp: oldTimestampMs },
{ role: 'assistant', content: 'Existing reply', timestamp: oldTimestampMs + 1000 },
];
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
success: true,
result: {
sessions: [{
key: MAIN_SESSION_KEY,
displayName: 'main',
updatedAt: oldTimestampMs,
}],
},
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200 }])]: {
success: true,
result: { messages: seededHistory },
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000 }])]: {
success: true,
result: { messages: seededHistory },
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345 },
},
},
[stableStringify(['/api/agents', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { success: true, agents: [{ id: 'main', name: 'Main' }] },
},
},
},
});
const page = await getStableWindow(app);
try {
await page.reload();
} catch (error) {
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
throw error;
}
}
await expect(page.getByText('Existing conversation')).toBeVisible({ timeout: 30_000 });
await page.getByTestId('sidebar-new-chat').click();
await expect(page.getByTestId('session-bucket-today').getByText(/agent:main:session-/)).toBeVisible();
await expect(page.getByTestId('session-bucket-older')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -0,0 +1,44 @@
import { closeElectronApp, expect, getStableWindow, test } from './fixtures/electron';
test.describe('hover-only scrollbar visibility', () => {
test('hides scrollbars until a scroll container is hovered', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
try {
const page = await getStableWindow(app);
await page.getByTestId('sidebar-nav-models').click();
await expect(page.getByTestId('models-page')).toBeVisible();
const scrollContainer = page.locator('[data-testid="models-page"] .overflow-y-auto').first();
await expect(scrollContainer).toBeVisible();
const beforeHover = await scrollContainer.evaluate((element) => {
const style = window.getComputedStyle(element);
const thumbStyle = window.getComputedStyle(element, '::-webkit-scrollbar-thumb');
return {
scrollbarWidth: style.scrollbarWidth,
thumbBackground: thumbStyle.backgroundColor,
};
});
await expect(scrollContainer).toHaveCSS('scrollbar-width', 'thin');
expect(beforeHover.thumbBackground).toBe('rgba(0, 0, 0, 0)');
await scrollContainer.hover();
const afterHover = await scrollContainer.evaluate((element) => {
const style = window.getComputedStyle(element);
const thumbStyle = window.getComputedStyle(element, '::-webkit-scrollbar-thumb');
return {
scrollbarWidth: style.scrollbarWidth,
thumbBackground: thumbStyle.backgroundColor,
};
});
expect(afterHover.scrollbarWidth).toBe('thin');
expect(afterHover.thumbBackground).not.toBe('rgba(0, 0, 0, 0)');
} finally {
await closeElectronApp(app);
}
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getSessionActivityMs, getSessionBucket } from '@/components/layout/session-buckets';
describe('sidebar session date buckets', () => {
it('uses the timestamp embedded in a locally-created session key as activity fallback', () => {
const createdAtMs = new Date('2026-05-06T10:00:00.000Z').getTime();
const nowMs = new Date('2026-05-06T12:00:00.000Z').getTime();
const session = {
key: `agent:main:session-${createdAtMs}`,
displayName: `agent:main:session-${createdAtMs}`,
};
const activityMs = getSessionActivityMs(session, {});
expect(activityMs).toBe(createdAtMs);
expect(getSessionBucket(activityMs, nowMs)).toBe('today');
});
it('prefers real message activity over backend metadata or key creation time', () => {
const keyCreatedAtMs = new Date('2026-05-06T10:00:00.000Z').getTime();
const updatedAtMs = new Date('2026-05-06T11:00:00.000Z').getTime();
const messageActivityMs = new Date('2026-05-06T12:00:00.000Z').getTime();
expect(getSessionActivityMs(
{
key: `agent:main:session-${keyCreatedAtMs}`,
updatedAt: updatedAtMs,
},
{ [`agent:main:session-${keyCreatedAtMs}`]: messageActivityMs },
)).toBe(messageActivityMs);
});
});