diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 3818df1..438853f 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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() {
{sessionBuckets.map((bucket) => ( bucket.sessions.length > 0 ? ( -
+
{bucket.label}
diff --git a/src/components/layout/session-buckets.ts b/src/components/layout/session-buckets.ts new file mode 100644 index 0000000..839c609 --- /dev/null +++ b/src/components/layout/session-buckets.ts @@ -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, +): 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; +} diff --git a/src/styles/globals.css b/src/styles/globals.css index 787af1b..68e2ce1 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -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; } diff --git a/tests/e2e/chat-new-session-date.spec.ts b/tests/e2e/chat-new-session-date.spec.ts new file mode 100644 index 0000000..beb8153 --- /dev/null +++ b/tests/e2e/chat-new-session-date.spec.ts @@ -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) + .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); + } + }); +}); diff --git a/tests/e2e/scrollbar-visibility.spec.ts b/tests/e2e/scrollbar-visibility.spec.ts new file mode 100644 index 0000000..2c84f89 --- /dev/null +++ b/tests/e2e/scrollbar-visibility.spec.ts @@ -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); + } + }); +}); diff --git a/tests/unit/sidebar-session-buckets.test.ts b/tests/unit/sidebar-session-buckets.test.ts new file mode 100644 index 0000000..bc14e2c --- /dev/null +++ b/tests/unit/sidebar-session-buckets.test.ts @@ -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); + }); +});