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