mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
55ef425d0c
commit
ed6ec0c175
13 changed files with 720 additions and 15 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1706,6 +1706,13 @@ export const ja = defineLocale({
|
|||
ageDay: '日',
|
||||
ageHour: '時間',
|
||||
ageMin: '分'
|
||||
},
|
||||
dateDivider: {
|
||||
today: '今日の早い時間',
|
||||
yesterday: '昨日',
|
||||
thisWeek: '今週',
|
||||
lastWeek: '先週',
|
||||
thisMonth: '今月'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1486,6 +1486,13 @@ export interface Translations {
|
|||
ageHour: string
|
||||
ageMin: string
|
||||
}
|
||||
dateDivider: {
|
||||
today: string
|
||||
yesterday: string
|
||||
thisWeek: string
|
||||
lastWeek: string
|
||||
thisMonth: string
|
||||
}
|
||||
}
|
||||
|
||||
composer: {
|
||||
|
|
|
|||
|
|
@ -1652,6 +1652,13 @@ export const zhHant = defineLocale({
|
|||
ageDay: '天',
|
||||
ageHour: '時',
|
||||
ageMin: '分'
|
||||
},
|
||||
dateDivider: {
|
||||
today: '今天稍早',
|
||||
yesterday: '昨天',
|
||||
thisWeek: '本週',
|
||||
lastWeek: '上週',
|
||||
thisMonth: '本月'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1962,6 +1962,13 @@ export const zh: Translations = {
|
|||
ageDay: '天',
|
||||
ageHour: '时',
|
||||
ageMin: '分'
|
||||
},
|
||||
dateDivider: {
|
||||
today: '今天早些时候',
|
||||
yesterday: '昨天',
|
||||
thisWeek: '本周',
|
||||
lastWeek: '上周',
|
||||
thisMonth: '本月'
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
216
apps/desktop/src/lib/session-date-groups.test.ts
Normal file
216
apps/desktop/src/lib/session-date-groups.test.ts
Normal 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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
151
apps/desktop/src/lib/session-date-groups.ts
Normal file
151
apps/desktop/src/lib/session-date-groups.ts
Normal 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' }))
|
||||
}
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue