Update sidebar history buckets (#1034)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Haze <hazeone@users.noreply.github.com>
This commit is contained in:
Haze
2026-05-18 16:52:29 +08:00
committed by GitHub
parent a417d4ec6b
commit 566a2d752d
8 changed files with 150 additions and 39 deletions

View File

@@ -22,6 +22,7 @@ import {
X,
Cpu,
Moon,
ChevronRight,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { rendererExtensionRegistry } from '@/extensions/registry';
@@ -85,6 +86,12 @@ function NavItem({ to, icon, label, badge, collapsed, onClick, testId }: NavItem
}
const INITIAL_NOW_MS = Date.now();
const DEFAULT_EXPANDED_SESSION_BUCKETS: Record<SessionBucketKey, boolean> = {
today: true,
withinWeek: true,
withinMonth: false,
older: false,
};
function getAgentIdFromSessionKey(sessionKey: string): string {
if (!sessionKey.startsWith('agent:')) return 'main';
@@ -173,6 +180,9 @@ export function Sidebar() {
const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
const [editingLabel, setEditingLabel] = useState('');
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
const [expandedSessionBuckets, setExpandedSessionBuckets] = useState<Record<SessionBucketKey, boolean>>(
() => ({ ...DEFAULT_EXPANDED_SESSION_BUCKETS }),
);
useEffect(() => {
const timer = window.setInterval(() => {
@@ -216,6 +226,13 @@ export function Sidebar() {
}
};
const toggleSessionBucket = (bucketKey: SessionBucketKey) => {
setExpandedSessionBuckets((current) => ({
...current,
[bucketKey]: !current[bucketKey],
}));
};
const stopResizing = useCallback(() => {
stopResizeRef.current?.();
stopResizeRef.current = null;
@@ -261,9 +278,7 @@ export function Sidebar() {
);
const sessionBuckets: Array<{ key: SessionBucketKey; label: string; sessions: typeof sessions }> = [
{ key: 'today', label: t('chat:historyBuckets.today'), sessions: [] },
{ key: 'yesterday', label: t('chat:historyBuckets.yesterday'), sessions: [] },
{ key: 'withinWeek', label: t('chat:historyBuckets.withinWeek'), sessions: [] },
{ key: 'withinTwoWeeks', label: t('chat:historyBuckets.withinTwoWeeks'), sessions: [] },
{ key: 'withinMonth', label: t('chat:historyBuckets.withinMonth'), sessions: [] },
{ key: 'older', label: t('chat:historyBuckets.older'), sessions: [] },
];
@@ -382,13 +397,30 @@ 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-1">
{sessionBuckets.map((bucket) => (
bucket.sessions.length > 0 ? (
{sessionBuckets.map((bucket) => {
const isBucketExpanded = expandedSessionBuckets[bucket.key] ?? false;
return (
<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>
{bucket.sessions.map((s) => {
<button
type="button"
data-testid={`session-bucket-toggle-${bucket.key}`}
aria-expanded={isBucketExpanded}
onClick={() => toggleSessionBucket(bucket.key)}
className={cn(
'flex w-full items-center gap-1 rounded-md px-2.5 py-1 text-left text-tiny font-medium',
'text-muted-foreground/60 tracking-tight transition-colors',
'hover:bg-black/5 hover:text-muted-foreground dark:hover:bg-white/5',
)}
>
<ChevronRight
className={cn(
'h-3 w-3 shrink-0 transition-transform',
isBucketExpanded && 'rotate-90',
)}
/>
<span>{bucket.label}</span>
</button>
{isBucketExpanded && bucket.sessions.map((s) => {
const agentId = getAgentIdFromSessionKey(s.key);
const agentName = agentNameById[agentId] || agentId;
const isEditing = editingSessionKey === s.key;
@@ -482,8 +514,8 @@ export function Sidebar() {
);
})}
</div>
) : null
))}
);
})}
</div>
)}

View File

@@ -1,10 +1,10 @@
import type { ChatSession } from '@/stores/chat';
const DAY_MS = 24 * 60 * 60 * 1000;
export type SessionBucketKey =
| 'today'
| 'yesterday'
| 'withinWeek'
| 'withinTwoWeeks'
| 'withinMonth'
| 'older';
@@ -13,15 +13,10 @@ export function getSessionBucket(activityMs: number, nowMs: number): SessionBuck
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';
if (activityMs >= startOfToday - 7 * DAY_MS) return 'withinWeek';
if (activityMs >= startOfToday - 30 * DAY_MS) return 'withinMonth';
return 'older';
}

View File

@@ -189,11 +189,9 @@
},
"historyBuckets": {
"today": "Today",
"yesterday": "Yesterday",
"withinWeek": "Within 1 Week",
"withinTwoWeeks": "Within 2 Weeks",
"withinMonth": "Within 1 Month",
"older": "Older than 1 Month"
"withinWeek": "Last 7 days",
"withinMonth": "Last 30 days",
"older": "Older"
},
"questionDirectory": {
"title": "Question directory",

View File

@@ -189,11 +189,9 @@
},
"historyBuckets": {
"today": "今日",
"yesterday": "昨日",
"withinWeek": "1週間以内",
"withinTwoWeeks": "2週間以内",
"withinMonth": "1か月以内",
"older": "1か月より前"
"withinWeek": "過去7日間",
"withinMonth": "過去30日間",
"older": "それ以前"
},
"questionDirectory": {
"title": "質問目次",

View File

@@ -189,11 +189,9 @@
},
"historyBuckets": {
"today": "Сегодня",
"yesterday": "Вчера",
"withinWeek": "В течение недели",
"withinTwoWeeks": "В течение 2 недель",
"withinMonth": "В течение месяца",
"older": "Старее месяца"
"withinWeek": "За последние 7 дней",
"withinMonth": "За последние 30 дней",
"older": "Ранее"
},
"questionDirectory": {
"title": "Список вопросов",

View File

@@ -189,11 +189,9 @@
},
"historyBuckets": {
"today": "今天",
"yesterday": "昨天",
"withinWeek": "一周内",
"withinTwoWeeks": "两周内",
"withinMonth": "一个月内",
"older": "一个月之前"
"withinMonth": "一月内",
"older": "更早"
},
"questionDirectory": {
"title": "问题目录",

View File

@@ -1,6 +1,11 @@
import { closeElectronApp, expect, getStableWindow, installIpcMocks, test } from './fixtures/electron';
const MAIN_SESSION_KEY = 'agent:main:main';
const DAY_MS = 24 * 60 * 60 * 1000;
const SESSIONS_LIST_PAYLOAD = {
includeDerivedTitles: true,
includeLastMessage: true,
};
function stableStringify(value: unknown): string {
if (value == null || typeof value !== 'object') return JSON.stringify(value);
@@ -12,6 +17,81 @@ function stableStringify(value: unknown): string {
}
test.describe('ClawX chat session date grouping', () => {
test('shows four collapsible history buckets with only recent buckets expanded', async ({ launchElectronApp }) => {
const app = await launchElectronApp({ skipSetup: true });
const nowMs = Date.now();
const sessions = [
{ key: MAIN_SESSION_KEY, displayName: 'Today conversation', updatedAt: nowMs - 60 * 60 * 1000 },
{ key: `agent:main:session-${nowMs - 2 * DAY_MS}`, displayName: 'Week conversation', updatedAt: nowMs - 2 * DAY_MS },
{ key: `agent:main:session-${nowMs - 10 * DAY_MS}`, displayName: 'Month conversation', updatedAt: nowMs - 10 * DAY_MS },
{ key: `agent:main:session-${nowMs - 40 * DAY_MS}`, displayName: 'Older conversation', updatedAt: nowMs - 40 * DAY_MS },
];
try {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345, connectedAt: nowMs },
gatewayRpc: {
[stableStringify(['sessions.list', SESSIONS_LIST_PAYLOAD])]: {
success: true,
result: { sessions },
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 200 }])]: {
success: true,
result: { messages: [] },
},
[stableStringify(['chat.history', { sessionKey: MAIN_SESSION_KEY, limit: 1000 }])]: {
success: true,
result: { messages: [] },
},
},
hostApi: {
[stableStringify(['/api/gateway/status', 'GET'])]: {
ok: true,
data: {
status: 200,
ok: true,
json: { state: 'running', port: 18789, pid: 12345, connectedAt: nowMs },
},
},
[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.getByTestId('session-bucket-toggle-today')).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByTestId('session-bucket-toggle-withinWeek')).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByTestId('session-bucket-toggle-withinMonth')).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByTestId('session-bucket-toggle-older')).toHaveAttribute('aria-expanded', 'false');
await expect(page.getByTestId('session-bucket-today').getByText('Today conversation')).toBeVisible();
await expect(page.getByTestId('session-bucket-withinWeek').getByText('Week conversation')).toBeVisible();
await expect(page.getByText('Month conversation')).toHaveCount(0);
await expect(page.getByText('Older conversation')).toHaveCount(0);
await page.getByTestId('session-bucket-toggle-withinMonth').click();
await page.getByTestId('session-bucket-toggle-older').click();
await expect(page.getByTestId('session-bucket-withinMonth').getByText('Month conversation')).toBeVisible();
await expect(page.getByTestId('session-bucket-older').getByText('Older conversation')).toBeVisible();
} finally {
await closeElectronApp(app);
}
});
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;
@@ -24,7 +104,7 @@ test.describe('ClawX chat session date grouping', () => {
await installIpcMocks(app, {
gatewayStatus: { state: 'running', port: 18789, pid: 12345 },
gatewayRpc: {
[stableStringify(['sessions.list', {}])]: {
[stableStringify(['sessions.list', SESSIONS_LIST_PAYLOAD])]: {
success: true,
result: {
sessions: [{

View File

@@ -29,4 +29,16 @@ describe('sidebar session date buckets', () => {
{ [`agent:main:session-${keyCreatedAtMs}`]: messageActivityMs },
)).toBe(messageActivityMs);
});
it('groups activity into today, seven days, thirty days, and older buckets', () => {
const nowMs = new Date(2026, 4, 18, 8, 15).getTime();
expect(getSessionBucket(new Date(2026, 4, 18, 0, 0).getTime(), nowMs)).toBe('today');
expect(getSessionBucket(new Date(2026, 4, 17, 23, 59, 59, 999).getTime(), nowMs)).toBe('withinWeek');
expect(getSessionBucket(new Date(2026, 4, 11, 0, 0).getTime(), nowMs)).toBe('withinWeek');
expect(getSessionBucket(new Date(2026, 4, 10, 23, 59, 59, 999).getTime(), nowMs)).toBe('withinMonth');
expect(getSessionBucket(new Date(2026, 3, 18, 0, 0).getTime(), nowMs)).toBe('withinMonth');
expect(getSessionBucket(new Date(2026, 3, 17, 23, 59, 59, 999).getTime(), nowMs)).toBe('older');
expect(getSessionBucket(0, nowMs)).toBe('older');
});
});