From ed6ec0c1751d0eba0d05248937292c6919d19ca5 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Fri, 24 Jul 2026 10:17:22 -0500 Subject: [PATCH] feat(desktop): date dividers in the sessions sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Group the flat recents list and entered-project lanes by recency: an unlabelled head of the newest run of sessions (cut at a real break in activity, sized toward the most recent handful), then one divider per coarse calendar range — Earlier today / Yesterday / Earlier this week / Last week / Earlier this month / month / month + year. Empty ranges are skipped, the first rendered group is never labelled, branch clusters never split, and hand-ordered lists / pinned / project previews stay divider-free. --- apps/desktop/src/app/chat/sidebar/chrome.tsx | 16 ++ apps/desktop/src/app/chat/sidebar/index.tsx | 1 + .../src/app/chat/sidebar/sessions-section.tsx | 43 +++- .../app/chat/sidebar/virtual-session-list.tsx | 37 ++- apps/desktop/src/i18n/en.ts | 7 + apps/desktop/src/i18n/ja.ts | 7 + apps/desktop/src/i18n/types.ts | 7 + apps/desktop/src/i18n/zh-hant.ts | 7 + apps/desktop/src/i18n/zh.ts | 7 + .../src/lib/session-date-groups.test.ts | 216 ++++++++++++++++++ apps/desktop/src/lib/session-date-groups.ts | 151 ++++++++++++ apps/desktop/src/lib/time.test.ts | 94 +++++++- apps/desktop/src/lib/time.ts | 142 ++++++++++++ 13 files changed, 720 insertions(+), 15 deletions(-) create mode 100644 apps/desktop/src/lib/session-date-groups.test.ts create mode 100644 apps/desktop/src/lib/session-date-groups.ts diff --git a/apps/desktop/src/app/chat/sidebar/chrome.tsx b/apps/desktop/src/app/chat/sidebar/chrome.tsx index 7815d1fafcf..2a0f728503a 100644 --- a/apps/desktop/src/app/chat/sidebar/chrome.tsx +++ b/apps/desktop/src/app/chat/sidebar/chrome.tsx @@ -43,6 +43,22 @@ export function SidebarRowNest({ className, ...props }: React.ComponentProps<'di return } +/** + * Chronological date-bucket separator ("Yesterday" / "Last week" / "June") for + * the session list. One flat row — a small caption plus a hairline rule — so it + * groups sessions by recency without adding a level of indentation. + */ +export function SidebarDateDivider({ className, label, ...props }: React.ComponentProps<'div'> & { label: string }) { + return ( +
+ + {label} + +
+ ) +} + /** Outer grid — sole owner of row height. */ export function SidebarRowShell({ actions, diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 5b3f2b4b30c..41be9fcb4ae 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -1275,6 +1275,7 @@ export function ChatSidebar({ // virtualized long list, which must keep its own scroller. !recentsVirtualizes && COMPACT_FLAT )} + dateGrouped={inProject || !agentOrderManual} dndSensors={dndSensors} emptyState={ showSessionSkeletons ? ( diff --git a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx index 366f28a152e..d3a65e1d787 100644 --- a/apps/desktop/src/app/chat/sidebar/sessions-section.tsx +++ b/apps/desktop/src/app/chat/sidebar/sessions-section.tsx @@ -7,11 +7,14 @@ import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { SidebarGroup, SidebarGroupContent } from '@/components/ui/sidebar' import type { HermesGitWorktree } from '@/global' import type { SessionInfo } from '@/hermes' +import { useI18n } from '@/i18n' import { flattenSessionsWithBranches } from '@/lib/session-branch-tree' +import { groupEntriesByRecency, type SidebarListRow, toSessionRows } from '@/lib/session-date-groups' +import { sessionBucketLabel } from '@/lib/time' import { cn } from '@/lib/utils' import { sessionPinId } from '@/store/session' -import { SidebarCount } from './chrome' +import { SidebarCount, SidebarDateDivider } from './chrome' import { EnteredProjectContent, ProjectOverviewRow, @@ -139,6 +142,11 @@ interface SidebarSessionsSectionProps { // lists (Pinned / search results) in the All-profiles view, where no group // header communicates ownership (#66003). showProfileTags?: boolean + // Insert "Yesterday" / "Last week" date dividers into the chronological + // session list (flat recents + entered-project lanes). Off for hand-ordered + // lists, pinned, messaging groups, and the project overview, where the order + // isn't strictly by recency so a date bucket would be misleading. + dateGrouped?: boolean } export function SidebarSessionsSection({ @@ -179,8 +187,11 @@ export function SidebarSessionsSection({ onReorderProjects, projectBackRow, dndSensors, - showProfileTags = false + showProfileTags = false, + dateGrouped = false }: SidebarSessionsSectionProps) { + const { t } = useI18n() + const dividerLabels = t.sidebar.dateDivider const sectionOpen = collapsible ? open : true const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0)) // A defined project list is itself content (even an empty project should @@ -219,10 +230,30 @@ export function SidebarSessionsSection({ ) } + // A single flat/virtual/lane list row — either a date divider or a session. + const renderListRow = (row: SidebarListRow, draggable: boolean) => + row.kind === 'divider' ? ( + + ) : ( + renderRow(row.entry.session, draggable, row.entry.branchStem) + ) + // Sessions inside repos/worktrees are date-ordered and static. const renderRows = (items: SessionInfo[]) => flattenSessionsWithBranches(items).map(({ branchStem, session }) => renderRow(session, false, branchStem)) + // Same as `renderRows`, but with date dividers folded in — used for + // entered-project lanes so a lane spanning multiple days reads + // chronologically, matching the flat recents list. + const renderRowsDated = (items: SessionInfo[]) => { + const entries = flattenSessionsWithBranches(items) + + return (dateGrouped ? groupEntriesByRecency(entries) : toSessionRows(entries)).map(row => renderListRow(row, false)) + } + + // Flat recents as list rows: grouped by recency when enabled, plain otherwise. + const flatRows: SidebarListRow[] = dateGrouped ? groupEntriesByRecency(displayEntries) : toSessionRows(displayEntries) + const flatVirtualized = !showEmptyState && !groups?.length && @@ -254,7 +285,7 @@ export function SidebarSessionsSection({ onNewSession={onNewSessionInWorkspace} project={projectContent} removedSessionIds={removedSessionIds} - renderRows={renderRows} + renderRows={renderRowsDated} repoWorktrees={projectRepoWorktrees} /> ) : ( @@ -310,13 +341,13 @@ export function SidebarSessionsSection({ s.id)} onReorder={onReorderSessions} sensors={dndSensors}> - {displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))} + {flatRows.map(row => renderListRow(row, true))} ) } else { - inner = displayEntries.map(({ branchStem, session }) => renderRow(session, false, branchStem)) + inner = flatRows.map(row => renderListRow(row, false)) } // The virtualizer owns its own scroller, so suppress the wrapper's overflow diff --git a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx index e3d56051b8e..d9cfd6c0043 100644 --- a/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx +++ b/apps/desktop/src/app/chat/sidebar/virtual-session-list.tsx @@ -4,10 +4,13 @@ import { useVirtualizer } from '@tanstack/react-virtual' import { type FC, useCallback, useRef } from 'react' import type { SessionInfo } from '@/hermes' -import { type SidebarSessionEntry } from '@/lib/session-branch-tree' +import { useI18n } from '@/i18n' +import { type SidebarListRow } from '@/lib/session-date-groups' +import { sessionBucketLabel } from '@/lib/time' import { cn } from '@/lib/utils' import { sessionPinId } from '@/store/session' +import { SidebarDateDivider } from './chrome' import { SidebarSessionRow } from './session-row' interface SessionRowCommonProps { @@ -27,7 +30,7 @@ interface SessionRowCommonProps { interface VirtualSessionListProps { activeSessionId: null | string className?: string - entries: SidebarSessionEntry[] + rows: SidebarListRow[] onArchiveSession: (sessionId: string) => void onBranchSession?: (sessionId: string, profile?: string) => void onDeleteSession: (sessionId: string) => void @@ -45,7 +48,7 @@ const OVERSCAN_ROWS = 12 export const VirtualSessionList: FC = ({ activeSessionId, className, - entries, + rows: listRows, onArchiveSession, onBranchSession, onDeleteSession, @@ -56,12 +59,18 @@ export const VirtualSessionList: FC = ({ sortable, workingSessionIdSet }) => { + const { t } = useI18n() + const dividerLabels = t.sidebar.dateDivider const scrollerRef = useRef(null) const virtualizer = useVirtualizer({ - count: entries.length, + count: listRows.length, estimateSize: () => ROW_ESTIMATE_PX, - getItemKey: index => entries[index]?.session.id ?? index, + getItemKey: index => { + const row = listRows[index] + + return row ? (row.kind === 'divider' ? row.key : row.entry.session.id) : index + }, getScrollElement: () => scrollerRef.current, // jsdom-friendly default; the real rect takes over on first observe. initialRect: { height: 600, width: 240 }, @@ -74,13 +83,25 @@ export const VirtualSessionList: FC = ({ const paddingBottom = Math.max(0, totalSize - (virtualItems[virtualItems.length - 1]?.end ?? 0)) const rows = virtualItems.map(virtualItem => { - const entry = entries[virtualItem.index] + const row = listRows[virtualItem.index] - if (!entry) { + if (!row) { return null } - const { branchStem, session } = entry + // Dividers are non-sortable, self-measured rows interleaved with sessions. + if (row.kind === 'divider') { + return ( + + ) + } + + const { branchStem, session } = row.entry const reorderable = sortable && !branchStem const commonProps: SessionRowCommonProps = { diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 8e602867f90..68314a5da20 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -1773,6 +1773,13 @@ export const en: Translations = { ageDay: 'd', ageHour: 'h', ageMin: 'm' + }, + dateDivider: { + today: 'Earlier today', + yesterday: 'Yesterday', + thisWeek: 'Earlier this week', + lastWeek: 'Last week', + thisMonth: 'Earlier this month' } }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index d7dd7ec5651..590cd92d066 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -1706,6 +1706,13 @@ export const ja = defineLocale({ ageDay: '日', ageHour: '時間', ageMin: '分' + }, + dateDivider: { + today: '今日の早い時間', + yesterday: '昨日', + thisWeek: '今週', + lastWeek: '先週', + thisMonth: '今月' } }, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 65a93e3dda8..51bdbb263ba 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1486,6 +1486,13 @@ export interface Translations { ageHour: string ageMin: string } + dateDivider: { + today: string + yesterday: string + thisWeek: string + lastWeek: string + thisMonth: string + } } composer: { diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 5229164c0a7..44b9145baf2 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -1652,6 +1652,13 @@ export const zhHant = defineLocale({ ageDay: '天', ageHour: '時', ageMin: '分' + }, + dateDivider: { + today: '今天稍早', + yesterday: '昨天', + thisWeek: '本週', + lastWeek: '上週', + thisMonth: '本月' } }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 297d5156f2a..8b2bf1efece 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1962,6 +1962,13 @@ export const zh: Translations = { ageDay: '天', ageHour: '时', ageMin: '分' + }, + dateDivider: { + today: '今天早些时候', + yesterday: '昨天', + thisWeek: '本周', + lastWeek: '上周', + thisMonth: '本月' } }, diff --git a/apps/desktop/src/lib/session-date-groups.test.ts b/apps/desktop/src/lib/session-date-groups.test.ts new file mode 100644 index 00000000000..1bcaf587ece --- /dev/null +++ b/apps/desktop/src/lib/session-date-groups.test.ts @@ -0,0 +1,216 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import type { SidebarSessionEntry } from './session-branch-tree' +import { groupEntriesByRecency, toSessionRows } from './session-date-groups' + +const session = (id: string, overrides: Partial = {}): SessionInfo => + ({ + ended_at: null, + id, + input_tokens: 0, + is_active: false, + last_active: 0, + message_count: 1, + model: null, + output_tokens: 0, + preview: null, + source: 'cli', + started_at: 0, + title: id, + tool_call_count: 0, + ...overrides + }) as SessionInfo + +const entry = (s: SessionInfo, branchStem?: string): SidebarSessionEntry => + branchStem ? { branchStem, session: s } : { session: s } + +// Fixed "now": Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday). +// All tests pin a Monday week start so calendar boundaries are deterministic. +const NOW = new Date(2026, 5, 18, 12, 0, 0).getTime() +const MONDAY = 1 + +const at = (year: number, month: number, day: number, hour = 10, minute = 0): number => + Math.floor(new Date(year, month, day, hour, minute, 0).getTime() / 1000) + +const group = (entries: SidebarSessionEntry[], nowMs = NOW) => groupEntriesByRecency(entries, nowMs, MONDAY) + +const dividerKeys = (rows: ReturnType): string[] => + rows.flatMap(row => (row.kind === 'divider' ? [row.key] : [])) + +describe('groupEntriesByRecency', () => { + it('cuts the head after the most recent handful, then divides by coarse ranges', () => { + // The morning run (30m/30m/4h/30m gaps, then a 14h silence) is the + // unlabelled head; each older group gets one divider, coarsening with age. + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 11) })), + entry(session('b', { last_active: at(2026, 5, 18, 10, 30) })), + entry(session('c', { last_active: at(2026, 5, 18, 10) })), + entry(session('d', { last_active: at(2026, 5, 18, 6) })), + entry(session('e', { last_active: at(2026, 5, 18, 5, 30) })), + entry(session('f', { last_active: at(2026, 5, 17, 15) })), // yesterday + entry(session('g', { last_active: at(2026, 5, 16, 15) })), // Tue this week + entry(session('h', { last_active: at(2026, 5, 14) })), // Sun last week + entry(session('i', { last_active: at(2026, 5, 3) })), // earlier in June + entry(session('j', { last_active: at(2026, 4, 28) })), // May + entry(session('k', { last_active: at(2025, 11, 3) })) // December 2025 + ]) + + expect(rows.slice(0, 5).every(row => row.kind === 'session')).toBe(true) + expect(rows[5]).toMatchObject({ key: 'yesterday', kind: 'divider' }) + expect(dividerKeys(rows)).toEqual(['yesterday', 'this-week', 'last-week', 'this-month', 'm-2026-4', 'my-2025-11']) + }) + + it('labels the rest of the current day "earlier today" past a real break', () => { + // Five rapid-fire sessions, a ~4h pause, then more of the same day. + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 11) })), + entry(session('b', { last_active: at(2026, 5, 18, 10, 58) })), + entry(session('c', { last_active: at(2026, 5, 18, 10, 56) })), + entry(session('d', { last_active: at(2026, 5, 18, 10, 54) })), + entry(session('e', { last_active: at(2026, 5, 18, 10, 52) })), + entry(session('f', { last_active: at(2026, 5, 18, 7) })), + entry(session('g', { last_active: at(2026, 5, 18, 6, 58) })) + ]) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(5) + expect(dividerKeys(rows)).toEqual(['today']) + }) + + it('never slices a rapid-fire burst mid-run', () => { + // Eleven sessions two minutes apart: no gap qualifies as a break, so the + // whole burst stays in the head and the divider lands after it. + const burst = Array.from({ length: 11 }, (_, i) => + entry(session(`s${i}`, { last_active: at(2026, 5, 18, 11) - i * 120 })) + ) + + const rows = group([...burst, entry(session('old', { last_active: at(2026, 5, 17, 15) }))]) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(11) + expect(dividerKeys(rows)).toEqual(['yesterday']) + }) + + it('chains the head run across midnight', () => { + // Viewed at 00:58: tonight plus last evening is one run; yesterday's + // afternoon (a different nominal day) opens the labelled groups. + const smallHours = new Date(2026, 5, 19, 0, 58).getTime() + + const rows = group( + [ + entry(session('a', { last_active: at(2026, 5, 19, 0, 30) })), + entry(session('b', { last_active: at(2026, 5, 18, 23, 50) })), + entry(session('c', { last_active: at(2026, 5, 18, 23, 20) })), + entry(session('d', { last_active: at(2026, 5, 17, 20) })) + ], + smallHours + ) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(3) + expect(dividerKeys(rows)).toEqual(['yesterday']) + }) + + it('dissolves a stale head into its own calendar group (fuzzy merge)', () => { + // Newest session is 6 days old and the rows below it share its "last week" + // bucket: cutting there would strand near-identical neighbours around a + // divider, so no head is kept and the whole bucket leads unlabelled. + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 12) })), + entry(session('b', { last_active: at(2026, 5, 11, 15) })), + entry(session('c', { last_active: at(2026, 5, 11, 10) })), + entry(session('d', { last_active: at(2026, 5, 3) })), + entry(session('e', { last_active: at(2026, 4, 20) })) + ]) + + expect(rows.slice(0, 3).every(row => row.kind === 'session')).toBe(true) + expect(dividerKeys(rows)).toEqual(['this-month', 'm-2026-4']) + }) + + it('keeps an isolated newest session as the head when its bucket differs', () => { + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 9) })), + entry(session('b', { last_active: at(2026, 5, 17, 15) })), + entry(session('c', { last_active: at(2026, 5, 3) })) + ]) + + expect(rows.findIndex(row => row.kind === 'divider')).toBe(1) + expect(dividerKeys(rows)).toEqual(['yesterday', 'this-month']) + }) + + it('emits no dividers when everything is one unbroken run', () => { + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 18, 11) })), + entry(session('b', { last_active: at(2026, 5, 18, 10, 50) })), + entry(session('c', { last_active: at(2026, 5, 18, 10, 40) })) + ]) + + expect(rows.every(row => row.kind === 'session')).toBe(true) + }) + + it('collapses a big gap straight to the next month/year (empty ranges omitted)', () => { + const rows = group([ + entry(session('t', { last_active: at(2026, 5, 18, 11) })), + entry(session('t2', { last_active: at(2026, 5, 18, 10, 30) })), + entry(session('j1', { last_active: at(2026, 0, 5) })), + entry(session('j2', { last_active: at(2026, 0, 3) })), + entry(session('old', { last_active: at(2024, 2, 9) })) + ]) + + expect(dividerKeys(rows)).toEqual(['m-2026-0', 'my-2024-2']) + }) + + it('never labels the first rendered group, even when it is not recent', () => { + // Newest session is weeks old and alone in its month: it opens the list + // unlabelled; only the transitions below it are marked. + const rows = group([ + entry(session('a', { last_active: at(2026, 4, 20) })), + entry(session('b', { last_active: at(2026, 2, 3) })), + entry(session('c', { last_active: at(2025, 11, 3) })) + ]) + + expect(rows[0]).toMatchObject({ kind: 'session' }) + expect(dividerKeys(rows)).toEqual(['m-2026-2', 'my-2025-11']) + }) + + it('keeps branch children in their parent cluster without opening a new bucket', () => { + const parent = session('parent', { last_active: at(2026, 5, 18, 11) }) + const child = session('child', { last_active: at(2024, 0, 1), parent_session_id: 'parent' }) + + const rows = group([entry(parent), entry(child, '└─ ')]) + + expect(rows).toEqual([ + { entry: entry(parent), kind: 'session' }, + { entry: entry(child, '└─ '), kind: 'session' } + ]) + }) + + it('never emits a divider twice under a non-monotonic order', () => { + const rows = group([ + entry(session('a', { last_active: at(2026, 5, 16) })), // head run + entry(session('b', { last_active: at(2026, 4, 5) })), // May — divider + entry(session('c', { last_active: at(2026, 5, 16) })) // head again — no repeat + ]) + + expect(dividerKeys(rows)).toEqual(['m-2026-4']) + }) + + it('falls back to started_at when last_active is missing', () => { + const rows = group([ + entry(session('head', { last_active: at(2026, 5, 18, 11) })), + entry(session('s', { last_active: 0, started_at: at(2026, 5, 10) })) + ]) + + expect(dividerKeys(rows)).toEqual(['last-week']) + }) +}) + +describe('toSessionRows', () => { + it('wraps entries as session rows with no dividers', () => { + const entries = [entry(session('a')), entry(session('b'), '└─ ')] + + expect(toSessionRows(entries)).toEqual([ + { entry: entries[0], kind: 'session' }, + { entry: entries[1], kind: 'session' } + ]) + }) +}) diff --git a/apps/desktop/src/lib/session-date-groups.ts b/apps/desktop/src/lib/session-date-groups.ts new file mode 100644 index 00000000000..13a5e642c28 --- /dev/null +++ b/apps/desktop/src/lib/session-date-groups.ts @@ -0,0 +1,151 @@ +import { type SidebarSessionEntry } from '@/lib/session-branch-tree' +import { calendarBucket, HOUR, localeWeekStartDay, MINUTE, SECOND, type SessionBucket } from '@/lib/time' + +// A flat list row is either a chronological date-bucket divider or a session +// entry. Interleaving these lets the flat list (and the virtualizer) render +// date separators inline without a second layer of nesting. +export type SidebarListRow = + | { bucket: SessionBucket; key: string; kind: 'divider' } + | { entry: SidebarSessionEntry; kind: 'session' } + +// The row's own age label reads from `last_active || started_at`; bucket off the +// same value so a divider lines up with what the row actually shows. +const recencyMs = (entry: SidebarSessionEntry): number => + (entry.session.last_active || entry.session.started_at || 0) * SECOND + +// Aim the head at "the most recent handful". A break shorter than +// MIN_RUN_BREAK_MS never counts as one — that would slice a rapid-fire burst — +// and a silence longer than MAX_RUN_GAP_MS always ends the run: without that +// bound a sparse list (a project lane) would chain weeks of stale sessions +// into one giant "recent" head. +const TARGET_HEAD_SESSIONS = 5 +const MIN_RUN_BREAK_MS = 30 * MINUTE +const MAX_RUN_GAP_MS = 8 * HOUR + +// The unlabelled head is the newest run of sessions, cut at a *real* break in +// activity. Candidate cut points are every gap of at least MIN_RUN_BREAK_MS +// inside the contiguous run (gaps ≤ MAX_RUN_GAP_MS), plus the run's own end; +// among them we pick the one whose head size lands closest (log-scale) to +// TARGET_HEAD_SESSIONS. So the first divider shows up after roughly the most +// recent five sessions — but only ever at a genuine pause, never mid-burst: a +// truly unbroken run stays whole, and an isolated newest session stands alone. +// Runs chain naturally across midnight. +// +// Fuzzy-merge rule: when the cut falls at the run's end and the sessions just +// below it share the head's calendar bucket, the head adds nothing — it's just +// the top of that group. Dissolve it (the first-group rule keeps the top +// unlabelled anyway) so a divider never strands near-identical neighbours, +// e.g. a lone 6-day-old session above a "Last week" label. +// +// Returns the oldest timestamp (ms) still inside the head; -Infinity means the +// whole list is one run, +Infinity means no head (calendar groups own it all). +function headRunCutoffMs(entries: readonly SidebarSessionEntry[], nowMs: number, weekStartsOn: number): number { + const times = entries + .filter(entry => !entry.branchStem) + .map(recencyMs) + .sort((a, b) => b - a) + + let bestIdx = -1 + let bestScore = Number.POSITIVE_INFINITY + let runEnded = false + + for (let i = 1; i < times.length; i++) { + const gap = times[i - 1] - times[i] + const endsRun = gap > MAX_RUN_GAP_MS + + if (gap >= MIN_RUN_BREAK_MS || endsRun) { + // `i` sessions would sit above a cut at this gap. + const score = Math.abs(Math.log(i / TARGET_HEAD_SESSIONS)) + + if (score < bestScore) { + bestScore = score + bestIdx = i + runEnded = endsRun + } + } + + if (endsRun) { + break + } + } + + if (bestIdx === -1) { + return Number.NEGATIVE_INFINITY + } + + if (runEnded) { + const headBucket = calendarBucket(times[0] / SECOND, nowMs, weekStartsOn) + const belowBucket = calendarBucket(times[bestIdx] / SECOND, nowMs, weekStartsOn) + + if (headBucket.key === belowBucket.key) { + return Number.POSITIVE_INFINITY + } + } + + return times[bestIdx - 1] +} + +// Insert a date divider before each labelled group. The unlabelled head is the +// newest run of sessions (see headRunCutoffMs); below it, groups are coarse +// calendar ranges — earlier today → yesterday → earlier this week → last week +// → earlier this month → month → month + year — one divider per range, never +// one per day. Whatever group happens to render first is also never labelled. +// Branch children inherit their parent cluster's group and never trigger a +// divider, so a parent→branches block never splits. +export function groupEntriesByRecency( + entries: readonly SidebarSessionEntry[], + nowMs = Date.now(), + weekStartsOn = localeWeekStartDay() +): SidebarListRow[] { + const rows: SidebarListRow[] = [] + const emitted = new Set() + const cutoff = headRunCutoffMs(entries, nowMs, weekStartsOn) + let lastKey: null | string = null + + for (const entry of entries) { + // Nested branch rows travel with their parent cluster; they never open a new + // bucket or move the divider cursor. + if (entry.branchStem) { + rows.push({ entry, kind: 'session' }) + + continue + } + + const ms = recencyMs(entry) + + // Head-run sessions are never labelled. + if (ms >= cutoff) { + rows.push({ entry, kind: 'session' }) + lastKey = '__recent__' + + continue + } + + const bucket = calendarBucket(ms / SECOND, nowMs, weekStartsOn) + + if (bucket.key !== lastKey) { + lastKey = bucket.key + const alreadyEmitted = emitted.has(bucket.key) + + // Mark it emitted even when skipped so a non-monotonic order (possible + // inside a project lane) can't later re-label it or collide React keys. + emitted.add(bucket.key) + + // A divider only ever separates two groups — never label the very first + // rendered row, whatever group it belongs to. + if (rows.length > 0 && !alreadyEmitted) { + rows.push({ bucket, key: bucket.key, kind: 'divider' }) + } + } + + rows.push({ entry, kind: 'session' }) + } + + return rows +} + +// Wrap entries as plain session rows (no dividers) so the ungrouped path shares +// the same `SidebarListRow[]` shape as the grouped one. +export function toSessionRows(entries: readonly SidebarSessionEntry[]): SidebarListRow[] { + return entries.map(entry => ({ entry, kind: 'session' })) +} diff --git a/apps/desktop/src/lib/time.test.ts b/apps/desktop/src/lib/time.test.ts index 90523fe0bd4..f767934c58b 100644 --- a/apps/desktop/src/lib/time.test.ts +++ b/apps/desktop/src/lib/time.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { DAY, formatAgo, HOUR, MINUTE, SECOND } from './time' +import { calendarBucket, DAY, formatAgo, HOUR, MINUTE, nominalDayStart, SECOND, sessionBucketLabel } from './time' const labels = { ageNow: 'now', @@ -30,3 +30,95 @@ describe('formatAgo', () => { expect(ago(-HOUR)).toBe('now') }) }) + +// Thursday 18 Jun 2026, local noon (15 Jun 2026 is a Monday). +const THU_NOON = new Date(2026, 5, 18, 12, 0, 0).getTime() + +const secondsAt = (year: number, month: number, day: number, hour = 10) => + Math.floor(new Date(year, month, day, hour, 0, 0).getTime() / 1000) + +describe('nominalDayStart', () => { + it('rolls the day boundary at 4 AM, not midnight', () => { + // 1 AM Saturday still belongs to Friday's run. + expect(nominalDayStart(new Date(2026, 5, 20, 1, 30).getTime())).toBe(new Date(2026, 5, 19).getTime()) + expect(nominalDayStart(new Date(2026, 5, 20, 4, 30).getTime())).toBe(new Date(2026, 5, 20).getTime()) + }) +}) + +describe('calendarBucket', () => { + // Monday week start: the current week began Mon 15 Jun, last week is Jun 8-14. + const MONDAY = 1 + + const kindAt = (year: number, month: number, day: number, hour = 10) => + calendarBucket(secondsAt(year, month, day, hour), THU_NOON, MONDAY).kind + + it('buckets the current day (and, defensively, the future) as today', () => { + // The head run normally absorbs these; "Earlier today" covers the rest. + expect(kindAt(2026, 5, 18, 5)).toBe('today') + expect(kindAt(2026, 5, 18, 23)).toBe('today') + expect(kindAt(2026, 5, 19)).toBe('today') + }) + + it('assigns the small hours to the previous evening', () => { + // 1 AM today (before the 4 AM rollover) is part of yesterday's run. + expect(kindAt(2026, 5, 18, 1)).toBe('yesterday') + + // And viewed at 00:58, last evening's sessions are still the current day. + const smallHours = new Date(2026, 5, 19, 0, 58).getTime() + + expect(calendarBucket(secondsAt(2026, 5, 18, 23), smallHours, MONDAY).kind).toBe('today') + expect(calendarBucket(secondsAt(2026, 5, 18, 10), smallHours, MONDAY).kind).toBe('today') + expect(calendarBucket(secondsAt(2026, 5, 17, 15), smallHours, MONDAY).kind).toBe('yesterday') + }) + + it('uses coarse, non-overlapping ranges that coarsen with age', () => { + expect(kindAt(2026, 5, 17)).toBe('yesterday') + expect(kindAt(2026, 5, 16)).toBe('thisWeek') // Tue this week + expect(kindAt(2026, 5, 15)).toBe('thisWeek') // Mon this week + expect(kindAt(2026, 5, 14)).toBe('lastWeek') // Sun last week + expect(kindAt(2026, 5, 8)).toBe('lastWeek') // Mon last week + expect(kindAt(2026, 5, 7)).toBe('thisMonth') // earlier in June + expect(kindAt(2026, 5, 1)).toBe('thisMonth') + expect(kindAt(2026, 4, 28)).toBe('month') // May, same year + expect(kindAt(2025, 11, 3)).toBe('monthYear') // December, prior year + }) + + it('respects a Sunday week start', () => { + // With the week starting Sun 14 Jun, that Sunday is this week, not last. + expect(calendarBucket(secondsAt(2026, 5, 14), THU_NOON, 0).kind).toBe('thisWeek') + expect(calendarBucket(secondsAt(2026, 5, 13), THU_NOON, 0).kind).toBe('lastWeek') + }) + + it('keys same-month sessions together and disambiguates across years', () => { + expect(calendarBucket(secondsAt(2026, 2, 3), THU_NOON, MONDAY).key).toBe('m-2026-2') + expect(calendarBucket(secondsAt(2026, 2, 20), THU_NOON, MONDAY).key).toBe('m-2026-2') + expect(calendarBucket(secondsAt(2025, 2, 3), THU_NOON, MONDAY).key).toBe('my-2025-2') + }) +}) + +describe('sessionBucketLabel', () => { + const labels = { + lastWeek: 'Last week', + thisMonth: 'Earlier this month', + thisWeek: 'Earlier this week', + today: 'Earlier today', + yesterday: 'Yesterday' + } + + const labelAt = (year: number, month: number, day: number) => + sessionBucketLabel(calendarBucket(secondsAt(year, month, day), THU_NOON, 1), labels) + + it('uses fixed labels for the relative buckets', () => { + expect(labelAt(2026, 5, 18)).toBe('Earlier today') + expect(labelAt(2026, 5, 17)).toBe('Yesterday') + expect(labelAt(2026, 5, 16)).toBe('Earlier this week') + expect(labelAt(2026, 5, 10)).toBe('Last week') + expect(labelAt(2026, 5, 2)).toBe('Earlier this month') + }) + + it('formats month (same year) and month + year (prior year) via Intl', () => { + // en-US default in the test env: month name, plus year for the prior year. + expect(labelAt(2026, 2, 3)).toBe('March') + expect(labelAt(2025, 11, 3)).toBe('December 2025') + }) +}) diff --git a/apps/desktop/src/lib/time.ts b/apps/desktop/src/lib/time.ts index da1d2915192..4f5c99ccf17 100644 --- a/apps/desktop/src/lib/time.ts +++ b/apps/desktop/src/lib/time.ts @@ -25,6 +25,12 @@ export const fmtDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medi // Date only, "5 Jun 2026" (starmap tooltip). export const fmtDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' }) +// Month name alone / with year — session-list date-bucket dividers ("September", +// "September 2025"). +export const fmtMonth = new Intl.DateTimeFormat(undefined, { month: 'long' }) +export const fmtMonthYear = new Intl.DateTimeFormat(undefined, { month: 'long', year: 'numeric' }) + + // ── Relative time ────────────────────────────────────────────────────────── const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto', style: 'short' }) @@ -50,6 +56,142 @@ export function relativeTime(targetMs: number, nowMs = Date.now()): string { return rtf.format(sign * Math.round(abs / DAY), 'day') } +// A dated divider bucket below the sidebar's unlabelled "recent" head cluster +// (see session-date-groups.ts for the clustering). Buckets are coarse, +// non-overlapping calendar ranges — one divider per *cluster* of activity, +// never one per day, and never a rolling window like "previous 7 days" that +// semantically overlaps the groups above it. `kind` drives the label; `at` is +// the session's nominal day start (ms) for month formatting. +export type SessionBucketKind = 'lastWeek' | 'month' | 'monthYear' | 'thisMonth' | 'thisWeek' | 'today' | 'yesterday' + +export interface SessionBucket { + at: number + key: string + kind: SessionBucketKind +} + +// Fixed divider labels, resolved from i18n (month labels come from Intl). +export interface SessionBucketLabels { + lastWeek: string + thisMonth: string + thisWeek: string + today: string + yesterday: string +} + +export const startOfLocalDay = (ms: number): number => { + const d = new Date(ms) + + return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() +} + +// The human day doesn't end at midnight — it ends when you sleep. Sessions +// from the small hours belong to the previous evening's run, so the day +// boundary sits at 4 AM local (same trick activity/sleep trackers use). +// A 12:30 AM session groups with 11:50 PM instead of splitting off. +export const DAY_ROLLOVER_HOUR = 4 + +// Start of the *nominal* local day a timestamp belongs to, honoring the 4 AM +// rollover: Saturday 1 AM → start of Friday. +export const nominalDayStart = (ms: number): number => startOfLocalDay(ms - DAY_ROLLOVER_HOUR * HOUR) + +// Locale-aware first day of week in JS getDay() convention (0=Sun … 6=Sat). +// Intl.Locale weekInfo reports 1=Mon … 7=Sun; unsupported → Monday. +export function localeWeekStartDay(): number { + try { + const locale = new Intl.Locale(new Intl.DateTimeFormat().resolvedOptions().locale) + const withWeekInfo = locale as { getWeekInfo?: () => { firstDay?: number }; weekInfo?: { firstDay?: number } } + const firstDay = (withWeekInfo.getWeekInfo?.() ?? withWeekInfo.weekInfo)?.firstDay + + return typeof firstDay === 'number' ? firstDay % 7 : 1 + } catch { + return 1 + } +} + +// Start of the local calendar week containing `ms` (DST-safe Date field math). +export function startOfLocalWeek(ms: number, weekStartsOn: number): number { + const d = new Date(startOfLocalDay(ms)) + const back = (d.getDay() - weekStartsOn + 7) % 7 + + return new Date(d.getFullYear(), d.getMonth(), d.getDate() - back).getTime() +} + +// Coarse calendar bucket for a Unix-seconds timestamp. Granularity coarsens +// with age: earlier today → yesterday → earlier this week → last week → +// earlier this month → month → month + year. Empty ranges simply never emit a +// bucket, so a sparse tail jumps straight to its month or month-year. The +// newest run of sessions never reaches here (it is the unlabelled head — see +// session-date-groups.ts), which is what makes "Earlier today" truthful. +export function calendarBucket( + seconds: number, + nowMs = Date.now(), + weekStartsOn = localeWeekStartDay() +): SessionBucket { + const nominal = nominalDayStart(seconds * SECOND) + const todayNominal = nominalDayStart(nowMs) + const dayDiff = Math.round((todayNominal - nominal) / DAY) + + if (dayDiff <= 0) { + return { at: nominal, key: 'today', kind: 'today' } + } + + if (dayDiff === 1) { + return { at: nominal, key: 'yesterday', kind: 'yesterday' } + } + + const weekStart = startOfLocalWeek(todayNominal, weekStartsOn) + + if (nominal >= weekStart) { + return { at: nominal, key: 'this-week', kind: 'thisWeek' } + } + + const ws = new Date(weekStart) + + if (nominal >= new Date(ws.getFullYear(), ws.getMonth(), ws.getDate() - 7).getTime()) { + return { at: nominal, key: 'last-week', kind: 'lastWeek' } + } + + const d = new Date(nominal) + const now = new Date(todayNominal) + const sameYear = d.getFullYear() === now.getFullYear() + + if (sameYear && d.getMonth() === now.getMonth()) { + return { at: nominal, key: 'this-month', kind: 'thisMonth' } + } + + const ym = `${d.getFullYear()}-${d.getMonth()}` + + return sameYear ? { at: nominal, key: `m-${ym}`, kind: 'month' } : { at: nominal, key: `my-${ym}`, kind: 'monthYear' } +} + +// Localized divider label for a bucket: fixed relative strings from i18n, +// Intl-formatted month / month-year for the rest. +export function sessionBucketLabel(bucket: SessionBucket, labels: SessionBucketLabels): string { + switch (bucket.kind) { + case 'today': + return labels.today + + case 'yesterday': + return labels.yesterday + + case 'thisWeek': + return labels.thisWeek + + case 'lastWeek': + return labels.lastWeek + + case 'thisMonth': + return labels.thisMonth + + case 'month': + return fmtMonth.format(bucket.at) + + case 'monthYear': + return fmtMonthYear.format(bucket.at) + } +} + export type ElapsedUnit = 'day' | 'hour' | 'minute' | 'second' // Coarsest elapsed bucket for a (clamped-nonnegative) duration, floored. The