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);
+ });
+});