feat(desktop): date dividers in the sessions sidebar

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.
This commit is contained in:
Brooklyn Nicholson 2026-07-24 10:17:22 -05:00
parent 55ef425d0c
commit ed6ec0c175
13 changed files with 720 additions and 15 deletions

View file

@ -43,6 +43,22 @@ export function SidebarRowNest({ className, ...props }: React.ComponentProps<'di
return <SidebarRowStack className={cn('pb-1 pl-4', className)} {...props} />
}
/**
* 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 (
<div className={cn('flex select-none items-center gap-2 px-2 pb-0.5 pt-2', className)} {...props}>
<span className="shrink-0 text-[0.64rem] font-semibold uppercase tracking-[0.12em] text-(--ui-text-quaternary)">
{label}
</span>
<span aria-hidden="true" className="h-px flex-1 bg-(--ui-stroke-tertiary)" />
</div>
)
}
/** Outer grid — sole owner of row height. */
export function SidebarRowShell({
actions,

View file

@ -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 ? (

View file

@ -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' ? (
<SidebarDateDivider key={row.key} label={sessionBucketLabel(row.bucket, dividerLabels)} />
) : (
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({
<VirtualSessionList
activeSessionId={activeSessionId}
className={contentClassName}
entries={displayEntries}
onArchiveSession={onArchiveSession}
onBranchSession={onBranchSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
rows={flatRows}
showProfileTags={showProfileTags}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
@ -334,11 +365,11 @@ export function SidebarSessionsSection({
} else if (sessionsDraggable && onReorderSessions) {
inner = (
<ReorderableList ids={sessions.map(s => s.id)} onReorder={onReorderSessions} sensors={dndSensors}>
{displayEntries.map(({ branchStem, session }) => renderRow(session, true, branchStem))}
{flatRows.map(row => renderListRow(row, true))}
</ReorderableList>
)
} 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

View file

@ -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<VirtualSessionListProps> = ({
activeSessionId,
className,
entries,
rows: listRows,
onArchiveSession,
onBranchSession,
onDeleteSession,
@ -56,12 +59,18 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
sortable,
workingSessionIdSet
}) => {
const { t } = useI18n()
const dividerLabels = t.sidebar.dateDivider
const scrollerRef = useRef<HTMLDivElement | null>(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<VirtualSessionListProps> = ({
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 (
<SidebarDateDivider
data-index={virtualItem.index}
key={row.key}
label={sessionBucketLabel(row.bucket, dividerLabels)}
ref={virtualizer.measureElement}
/>
)
}
const { branchStem, session } = row.entry
const reorderable = sortable && !branchStem
const commonProps: SessionRowCommonProps = {

View file

@ -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'
}
},

View file

@ -1706,6 +1706,13 @@ export const ja = defineLocale({
ageDay: '日',
ageHour: '時間',
ageMin: '分'
},
dateDivider: {
today: '今日の早い時間',
yesterday: '昨日',
thisWeek: '今週',
lastWeek: '先週',
thisMonth: '今月'
}
},

View file

@ -1486,6 +1486,13 @@ export interface Translations {
ageHour: string
ageMin: string
}
dateDivider: {
today: string
yesterday: string
thisWeek: string
lastWeek: string
thisMonth: string
}
}
composer: {

View file

@ -1652,6 +1652,13 @@ export const zhHant = defineLocale({
ageDay: '天',
ageHour: '時',
ageMin: '分'
},
dateDivider: {
today: '今天稍早',
yesterday: '昨天',
thisWeek: '本週',
lastWeek: '上週',
thisMonth: '本月'
}
},

View file

@ -1962,6 +1962,13 @@ export const zh: Translations = {
ageDay: '天',
ageHour: '时',
ageMin: '分'
},
dateDivider: {
today: '今天早些时候',
yesterday: '昨天',
thisWeek: '本周',
lastWeek: '上周',
thisMonth: '本月'
}
},

View file

@ -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> = {}): 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<typeof groupEntriesByRecency>): 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' }
])
})
})

View file

@ -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<string>()
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' }))
}

View file

@ -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')
})
})

View file

@ -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