chore(front): optimize front issue (#975)
This commit is contained in:
@@ -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>
|
||||
|
||||
48
src/components/layout/session-buckets.ts
Normal file
48
src/components/layout/session-buckets.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
85
tests/e2e/chat-new-session-date.spec.ts
Normal file
85
tests/e2e/chat-new-session-date.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
44
tests/e2e/scrollbar-visibility.spec.ts
Normal file
44
tests/e2e/scrollbar-visibility.spec.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
32
tests/unit/sidebar-session-buckets.test.ts
Normal file
32
tests/unit/sidebar-session-buckets.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user