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:
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -189,11 +189,9 @@
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "今日",
|
||||
"yesterday": "昨日",
|
||||
"withinWeek": "1週間以内",
|
||||
"withinTwoWeeks": "2週間以内",
|
||||
"withinMonth": "1か月以内",
|
||||
"older": "1か月より前"
|
||||
"withinWeek": "過去7日間",
|
||||
"withinMonth": "過去30日間",
|
||||
"older": "それ以前"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "質問目次",
|
||||
|
||||
@@ -189,11 +189,9 @@
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "Сегодня",
|
||||
"yesterday": "Вчера",
|
||||
"withinWeek": "В течение недели",
|
||||
"withinTwoWeeks": "В течение 2 недель",
|
||||
"withinMonth": "В течение месяца",
|
||||
"older": "Старее месяца"
|
||||
"withinWeek": "За последние 7 дней",
|
||||
"withinMonth": "За последние 30 дней",
|
||||
"older": "Ранее"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "Список вопросов",
|
||||
|
||||
@@ -189,11 +189,9 @@
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "今天",
|
||||
"yesterday": "昨天",
|
||||
"withinWeek": "一周内",
|
||||
"withinTwoWeeks": "两周内",
|
||||
"withinMonth": "一个月内",
|
||||
"older": "一个月之前"
|
||||
"withinMonth": "一月内",
|
||||
"older": "更早"
|
||||
},
|
||||
"questionDirectory": {
|
||||
"title": "问题目录",
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user