From db15943811aed8d6412ea37062939ca3facbe261 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Thu, 30 Jul 2026 12:58:44 -0400 Subject: [PATCH 1/3] feat(desktop): one relative date ladder across chat and the Inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four surfaces formatted dates four ways, and none matched the writing standard's Today / Yesterday / weekday / date progression: - the chat day divider rendered "Monday, March 31st" — ordinal suffixes, which the standard tells us to avoid - the Inbox section header showed "Yesterday" but never "Today", and always printed the year - the Inbox list row had a third implementation - the Inbox thread pane header showed "Jul 8, 2026, 2:34 PM" — always absolute, always with the year, never relative at any distance - a channel message header showed a bare clock time, so a message from last week read "9:05 AM" once its day divider scrolled out of view `shared/lib/datetime.ts` now owns two ladders, because there are two jobs: `formatDayGroupLabel` for a header that labels a group of items, and `formatItemTimestamp` for a single item's own timestamp. Two deliberate deviations from the standard, both documented at the call: - the oldest band keeps the day ("June 20, 2025", not "Aug 2022"). A group label has to identify its day; collapsing to month-and-year would give every day in a month the same divider. - item timestamps keep the time of day at every band in roomy surfaces ("Yesterday at 9:05 AM"). Chat is where you read conversation, so the time is content, not chrome. Narrow list rows still drop it and rely on the existing hover tooltip. `MessageTimestamp` now derives its labels from `createdAt` rather than taking a pre-formatted string, so the wording is not frozen at the time the message list was formatted. The 36px continuation hover gutter stays clock-only — a relative label does not fit. Also adds a middot between message-header metadata segments. "managed by you 9:53 AM" ran two unrelated facts together; it now reads "managed by you · 9:53 AM". The divider is aria-hidden and grouped with the segment it precedes, so it cannot wrap to the start of a line on its own. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Clay Delk --- desktop/src/features/home/lib/inbox.ts | 70 ++----- .../src/features/home/ui/InboxMessageRow.tsx | 43 +++- .../messages/lib/dateFormatters.test.mjs | 93 ++------- .../features/messages/lib/dateFormatters.ts | 91 ++------- .../features/messages/ui/MessageHeader.tsx | 23 +++ .../src/features/messages/ui/MessageRow.tsx | 56 ++++-- .../features/messages/ui/MessageTimestamp.tsx | 30 ++- .../features/messages/ui/SystemMessageRow.tsx | 29 ++- .../messages/ui/TimelineMessageList.tsx | 8 +- .../ui/messageTimestampContract.test.mjs | 41 ++++ desktop/src/shared/lib/datetime.test.mjs | 185 ++++++++++++++++++ desktop/src/shared/lib/datetime.ts | 162 +++++++++++++++ 12 files changed, 579 insertions(+), 252 deletions(-) create mode 100644 desktop/src/features/messages/ui/messageTimestampContract.test.mjs create mode 100644 desktop/src/shared/lib/datetime.test.mjs create mode 100644 desktop/src/shared/lib/datetime.ts diff --git a/desktop/src/features/home/lib/inbox.ts b/desktop/src/features/home/lib/inbox.ts index e34fa0c200..6f32a2f775 100644 --- a/desktop/src/features/home/lib/inbox.ts +++ b/desktop/src/features/home/lib/inbox.ts @@ -18,6 +18,10 @@ import type { HomeFeedResponse, RelayEvent, } from "@/shared/api/types"; +import { + formatDayGroupLabel, + formatItemTimestamp, +} from "@/shared/lib/datetime"; import { resolveMentionProps } from "@/shared/lib/resolveMentionNames"; export type InboxFilter = @@ -86,6 +90,7 @@ export type InboxReply = { */ signerPubkey?: string; tags?: string[][]; + /** Clock time only, for the hover gutter on continuation rows. */ timeLabel?: string; }; @@ -103,11 +108,6 @@ export type InboxGroup = { type InboxChannel = Pick; -const listTimeFormatter = new Intl.DateTimeFormat("en-US", { - hour: "numeric", - minute: "2-digit", -}); - const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", @@ -116,31 +116,6 @@ const fullTimeFormatter = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const shortDateFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", -}); - -const shortDateWithYearFormatter = new Intl.DateTimeFormat("en-US", { - month: "short", - day: "numeric", - year: "numeric", -}); - -const weekdayFormatter = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -function startOfDay(value: Date) { - return new Date(value.getFullYear(), value.getMonth(), value.getDate()); -} - -function diffInDays(from: Date, to: Date) { - return Math.round( - (startOfDay(from).getTime() - startOfDay(to).getTime()) / 86_400_000, - ); -} - function tagValue(item: FeedItem, name: string) { return item.tags.find((tag) => tag[0] === name)?.[1]?.trim() || null; } @@ -445,23 +420,7 @@ export function findInboxItemByEventId( } function formatInboxTimestamp(unixSeconds: number) { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - const dayDiff = diffInDays(now, date); - - if (dayDiff === 0) { - return listTimeFormatter.format(date); - } - - if (dayDiff === 1) { - return "Yesterday"; - } - - if (now.getFullYear() === date.getFullYear()) { - return shortDateFormatter.format(date); - } - - return shortDateWithYearFormatter.format(date); + return formatItemTimestamp(unixSeconds); } export function formatInboxFullTimestamp(unixSeconds: number) { @@ -480,21 +439,14 @@ export function relayEventFromFeedItem(item: FeedItem): RelayEvent { }; } -export function groupInboxItems(items: InboxItem[]): InboxGroup[] { +export function groupInboxItems( + items: InboxItem[], + nowSeconds = Date.now() / 1_000, +): InboxGroup[] { const groups = new Map(); - const now = new Date(); for (const item of items) { - const date = new Date(item.latestActivityAt * 1_000); - const dayDiff = diffInDays(now, date); - const label = - dayDiff === 0 - ? "Today" - : dayDiff === 1 - ? "Yesterday" - : dayDiff < 7 - ? weekdayFormatter.format(date) - : shortDateWithYearFormatter.format(date); + const label = formatDayGroupLabel(item.latestActivityAt, nowSeconds); const current = groups.get(label) ?? []; current.push(item); diff --git a/desktop/src/features/home/ui/InboxMessageRow.tsx b/desktop/src/features/home/ui/InboxMessageRow.tsx index 04deafb3ff..308640c93d 100644 --- a/desktop/src/features/home/ui/InboxMessageRow.tsx +++ b/desktop/src/features/home/ui/InboxMessageRow.tsx @@ -4,10 +4,12 @@ import { useKnownAgentPubkeys } from "@/features/agents/useKnownAgentPubkeys"; import type { InboxContextMessage } from "@/features/home/lib/inbox"; import { toTimelineMessage } from "@/features/home/lib/inboxViewHelpers"; import { formatTimeWithoutDayPeriod } from "@/features/messages/lib/dateFormatters"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import type { TimelineMessage } from "@/features/messages/types"; import { getConfigNudgeAuthorPubkey } from "@/features/messages/ui/configNudgeAuthPubkey"; import { MessageActionBar } from "@/features/messages/ui/MessageActionBar"; import { MessageAgentOwner } from "@/features/messages/ui/MessageAgentOwner"; +import { MessageMetaSeparator } from "@/features/messages/ui/MessageHeader"; import { MessageReactions } from "@/features/messages/ui/MessageReactions"; import { UnreadDivider } from "@/features/messages/ui/UnreadDivider"; import { useReactionHandler } from "@/features/messages/ui/useReactionHandler"; @@ -89,6 +91,21 @@ export function InboxMessageRow({ const hoverTimestampLabel = formatTimeWithoutDayPeriod( message.timeLabel ?? message.fullTimestampLabel, ); + // Derived here rather than plumbed in with the message: the thread pane has no + // day divider to supply the date, and deriving on render means a row does not + // keep saying "Today" after midnight. `fullTimestampLabel` stays the absolute + // value behind the hover title. + const timestampLabel = formatItemTimestamp(message.createdAt, { + withTime: true, + }); + const timestampNode = ( +

+ {timestampLabel} +

+ ); return (
@@ -185,14 +202,24 @@ export function InboxMessageRow({ {message.isAgent ? ( - - ) : null} -

- {message.fullTimestampLabel} -

+ <> + + {/* + Grouped with the timestamp so the divider never wraps to the + start of a line on its own. Gap matches the container's, so + spacing reads the same either side of the divider. + */} + + + {timestampNode} + + + ) : ( + timestampNode + )}
)} diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs index f579cbfcf6..138851b163 100644 --- a/desktop/src/features/messages/lib/dateFormatters.test.mjs +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -2,8 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import { - formatDayHeading, - formatShortMonthDayOrdinal, + formatShortMonthDay, formatThreadSummaryLastReplyTime, formatTimeWithoutDayPeriod, startOfLocalDaySeconds, @@ -13,66 +12,16 @@ function localUnixSeconds(year, monthIndex, day) { return new Date(year, monthIndex, day, 12).getTime() / 1_000; } -function weekday(date) { - return new Intl.DateTimeFormat("en-US", { weekday: "long" }).format(date); -} - -function month(date) { - return new Intl.DateTimeFormat("en-US", { month: "long" }).format(date); -} - -test("formatShortMonthDayOrdinal formats month before ordinal day", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 19)), - "May 19th", - ); +test("formatShortMonthDay abbreviates the month and omits the ordinal", () => { + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 19)), "May 19"); + assert.equal(formatShortMonthDay(localUnixSeconds(2026, 4, 1)), "May 1"); }); -test("formatShortMonthDayOrdinal handles ordinal suffixes", () => { - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 1)), - "May 1st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 2)), - "May 2nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 3)), - "May 3rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 4)), - "May 4th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 11)), - "May 11th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 12)), - "May 12th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 13)), - "May 13th", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 21)), - "May 21st", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 22)), - "May 22nd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 23)), - "May 23rd", - ); - assert.equal( - formatShortMonthDayOrdinal(localUnixSeconds(2026, 4, 31)), - "May 31st", - ); +test("no day carries an ordinal suffix", () => { + for (const day of [1, 2, 3, 4, 11, 12, 13, 21, 22, 23, 31]) { + const label = formatShortMonthDay(localUnixSeconds(2026, 4, day)); + assert.doesNotMatch(label, /\d(?:st|nd|rd|th)\b/, `ordinal in "${label}"`); + } }); test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => { @@ -108,31 +57,11 @@ test("formatThreadSummaryLastReplyTime expands relative units", () => { ); }); -test("formatThreadSummaryLastReplyTime uses ordinal dates for older replies", () => { +test("formatThreadSummaryLastReplyTime dates older replies without an ordinal", () => { const now = localUnixSeconds(2026, 5, 15); const replyAt = localUnixSeconds(2026, 4, 19); - assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19th"); -}); - -test("formatDayHeading omits the year for current-year dates", () => { - const now = new Date(); - const date = new Date(now.getFullYear(), (now.getMonth() + 6) % 12, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, ${month(date)} 19th`, - ); -}); - -test("formatDayHeading includes the year for other years", () => { - const year = new Date().getFullYear() - 1; - const date = new Date(year, 4, 19, 12); - - assert.equal( - formatDayHeading(date.getTime() / 1_000), - `${weekday(date)}, May 19th, ${year}`, - ); + assert.equal(formatThreadSummaryLastReplyTime(replyAt, now), "on May 19"); }); test("startOfLocalDaySeconds collapses a day's timestamps to one value", () => { diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index 04c85d8150..f752bdfd20 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -4,9 +4,17 @@ * - `formatTime` — short clock time ("2:34 PM"), used in message rows. * - `formatFullDateTime` — verbose string for tooltips * ("Wednesday, April 2, 2026 at 2:34 PM"). - * - `formatDayHeading` — label for day dividers / sticky headers. - * Returns "Today", "Yesterday", or a date like "Monday, March 31st". * - `isSameDay` — compare two unix-second timestamps. + * + * Relative labels ("Today", "Yesterday", "June 20", "Yesterday at 9:05 AM") are + * not here: chat and the Inbox share them from `shared/lib/datetime.ts`. What + * stays in this file is the absolute end of the range — a bare clock time, the + * verbose tooltip string, and same-day comparison. + * + * `formatTime` is for places with only enough room for a clock: the hover gutter + * that replaces the avatar on continuation rows. A message header uses the + * relative ladder instead, because the day divider that supplies its date + * scrolls away while the messages under it stay on screen. */ const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { @@ -25,16 +33,9 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { minute: "2-digit", }); -const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { - weekday: "long", -}); - -const LONG_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { - month: "long", -}); - -const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { +const SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", + day: "numeric", }); /** Short clock time, e.g. "2:34 PM". */ @@ -52,34 +53,6 @@ export function formatFullDateTime(unixSeconds: number): string { return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); } -/** - * Human-friendly day label for dividers and sticky headers. - * Returns "Today", "Yesterday", a current-year date like "Monday, March 31st", - * or a prior-year date like "Monday, March 31st, 2025". - */ -export function formatDayHeading(unixSeconds: number): string { - const date = new Date(unixSeconds * 1_000); - const now = new Date(); - - if (isSameDayDate(date, now)) { - return "Today"; - } - - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - if (isSameDayDate(date, yesterday)) { - return "Yesterday"; - } - - const dateLabel = `${WEEKDAY_FORMATTER.format(date)}, ${formatMonthDayOrdinal( - date, - LONG_MONTH_FORMATTER, - )}`; - return date.getFullYear() === now.getFullYear() - ? dateLabel - : `${dateLabel}, ${date.getFullYear()}`; -} - /** True when two unix-second timestamps fall on the same calendar day (local time). */ export function isSameDay(a: number, b: number): boolean { return isSameDayDate(new Date(a * 1_000), new Date(b * 1_000)); @@ -97,17 +70,14 @@ export function startOfLocalDaySeconds(unixSeconds: number): number { return Math.floor(date.getTime() / 1_000); } -/** Short month + ordinal day, e.g. "May 19th". */ -export function formatShortMonthDayOrdinal(unixSeconds: number): string { - return formatMonthDayOrdinal( - new Date(unixSeconds * 1_000), - SHORT_MONTH_FORMATTER, - ); +/** Short month + day, e.g. "May 19". No ordinal suffix, per the writing standard. */ +export function formatShortMonthDay(unixSeconds: number): string { + return SHORT_MONTH_DAY_FORMATTER.format(new Date(unixSeconds * 1_000)); } /** * Relative thread-summary timestamp with expanded units, e.g. "3 hours ago", - * falling back to "on May 19th" for older replies. + * falling back to "on May 19" for older replies. */ export function formatThreadSummaryLastReplyTime( unixSeconds: number, @@ -120,7 +90,7 @@ export function formatThreadSummaryLastReplyTime( if (diff < 86_400) return formatAgo(Math.floor(diff / 3_600), "hour"); if (diff < 604_800) return formatAgo(Math.floor(diff / 86_400), "day"); - return `on ${formatShortMonthDayOrdinal(unixSeconds)}`; + return `on ${formatShortMonthDay(unixSeconds)}`; } function isSameDayDate(a: Date, b: Date): boolean { @@ -131,33 +101,6 @@ function isSameDayDate(a: Date, b: Date): boolean { ); } -function formatMonthDayOrdinal( - date: Date, - monthFormatter: Intl.DateTimeFormat, -): string { - return `${monthFormatter.format(date)} ${date.getDate()}${ordinalSuffix( - date.getDate(), - )}`; -} - function formatAgo(value: number, unit: string): string { return `${value} ${unit}${value === 1 ? "" : "s"} ago`; } - -function ordinalSuffix(day: number): string { - const lastTwoDigits = day % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) { - return "th"; - } - - switch (day % 10) { - case 1: - return "st"; - case 2: - return "nd"; - case 3: - return "rd"; - default: - return "th"; - } -} diff --git a/desktop/src/features/messages/ui/MessageHeader.tsx b/desktop/src/features/messages/ui/MessageHeader.tsx index 7e54c1ce06..5e14e3cca8 100644 --- a/desktop/src/features/messages/ui/MessageHeader.tsx +++ b/desktop/src/features/messages/ui/MessageHeader.tsx @@ -23,6 +23,29 @@ export function MessageHeaderRow({ ); } +/** + * Divider between two pieces of message-header metadata. + * + * The header runs several independent facts together on one line, and without a + * divider they read as one phrase: an agent message came out as "managed by You + * 9:53 AM". A middot is what the rest of the app already uses for this + * (`MessageThreadSummaryRow`, project rows, the mention list). + * + * `aria-hidden` because the divider is punctuation for the eye only — the header + * already reads as separate nodes to a screen reader, and `MessageAgentOwner` + * supplies its own "Agent managed by" label. + * + * No horizontal margin: `MessageHeaderRow` is a flex row with `gap-x-1.5`, so + * spacing comes from the container. Adding margin here double-spaces it. + */ +export function MessageMetaSeparator() { + return ( + + ); +} + type MessageAuthorTextProps = { as?: "div" | "h3" | "span"; children: React.ReactNode; diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index afcb3e863c..0c088d8bed 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -41,7 +41,11 @@ import { Markdown } from "@/shared/ui/markdown"; import type { VideoReviewContext } from "@/shared/ui/VideoPlayer"; import { MessageActionBar } from "./MessageActionBar"; import { MessageAgentOwner } from "./MessageAgentOwner"; -import { MessageAuthorText, MessageHeaderRow } from "./MessageHeader"; +import { + MessageAuthorText, + MessageHeaderRow, + MessageMetaSeparator, +} from "./MessageHeader"; import { MessageTimestamp } from "./MessageTimestamp"; import { WaveMessageAttachment } from "./WaveMessageAttachment"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; @@ -428,7 +432,6 @@ export const MessageRow = React.memo( className="opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100" createdAt={message.createdAt} hideDayPeriod - time={message.time} /> ); @@ -520,11 +523,29 @@ export const MessageRow = React.memo( const inlineMetadataNode = (
- + {statusMetadataNode}
); + const personaNode = + message.personaDisplayName && + message.personaDisplayName !== message.author ? ( + + {message.personaDisplayName} + + ) : null; + + // Divider-separated, in order. The author name is deliberately not in this + // list: "Alice 9:53 AM" reads as a name followed by a time, while the + // metadata segments after it are unrelated facts that run together without + // one. + const headerMetadataNodes = [ + { key: "owner", node: agentOwnerNode }, + { key: "timestamp", node: inlineMetadataNode }, + { key: "persona", node: personaNode }, + ].filter((slot) => slot.node !== null); + const continuationMetadataNode = isContinuation && statusMetadataNode ? (
@@ -550,14 +571,22 @@ export const MessageRow = React.memo( ) : ( authorNode )} - {agentOwnerNode} - {inlineMetadataNode} - {message.personaDisplayName && - message.personaDisplayName !== message.author ? ( - - {message.personaDisplayName} - - ) : null} + {headerMetadataNodes.map(({ key, node }, index) => + index === 0 ? ( + {node} + ) : ( + // The divider is grouped with the segment it precedes so the two wrap + // together — as loose siblings in a flex-wrap row, a divider can end + // up alone at the start of the second line. + + + {node} + + ), + )} ); const bodyContainerClass = isContinuation ? "mt-0" : bodyOffsetClass; @@ -822,7 +851,10 @@ export const MessageRow = React.memo( prev.message.ownerLabel === next.message.ownerLabel && prev.message.avatarUrl === next.message.avatarUrl && prev.message.accent === next.message.accent && - prev.message.time === next.message.time && + // The header timestamp and the hover gutter both derive from createdAt, so + // that is the prop to compare. (`time` tracked it correctly — it is the same + // value formatted — but no longer names what this row reads.) + prev.message.createdAt === next.message.createdAt && prev.message.depth === next.message.depth && prev.message.kind === next.message.kind && prev.message.pending === next.message.pending && diff --git a/desktop/src/features/messages/ui/MessageTimestamp.tsx b/desktop/src/features/messages/ui/MessageTimestamp.tsx index cc164db359..53c62a1ae3 100644 --- a/desktop/src/features/messages/ui/MessageTimestamp.tsx +++ b/desktop/src/features/messages/ui/MessageTimestamp.tsx @@ -1,8 +1,10 @@ import { formatFullDateTime, + formatTime, formatTimeWithoutDayPeriod, } from "@/features/messages/lib/dateFormatters"; import { cn } from "@/shared/lib/cn"; +import { formatItemTimestamp } from "@/shared/lib/datetime"; import { Tooltip, TooltipContent, @@ -12,18 +14,40 @@ import { const TIMESTAMP_TOOLTIP_DELAY_MS = 500; +/** + * The timestamp beside a message author, and the clock that fades in over the + * avatar gutter on continuation rows. + * + * Both labels are derived from `createdAt` here rather than taken as a + * pre-formatted string, so the wording is recomputed on each render instead of + * being frozen at the time the message list was formatted. Note this does not + * make it live: `MessageRow` is memoized, so a row already on screen when the + * clock passes midnight keeps saying "Today" until something re-renders it. The + * day divider above it has the same property, and both correct themselves on the + * next message, scroll, or navigation. + * + * The two modes carry different information on purpose: + * + * - Header (default) — the full relative label ("Yesterday at 9:05 AM"). The day + * divider above the group says which day it is, but a divider scrolls out of + * view while its messages stay on screen, so a bare clock time on a row from + * last week has nothing to anchor it. + * - `hideDayPeriod` — clock only, minus the AM/PM marker. This renders in a + * 36px-wide gutter where the avatar would be, so it has room for "9:05" and + * nothing more. + */ export function MessageTimestamp({ className, createdAt, hideDayPeriod = false, - time, }: { className?: string; createdAt: number; hideDayPeriod?: boolean; - time: string; }) { - const displayTime = hideDayPeriod ? formatTimeWithoutDayPeriod(time) : time; + const displayTime = hideDayPeriod + ? formatTimeWithoutDayPeriod(formatTime(createdAt)) + : formatItemTimestamp(createdAt, { withTime: true }); return ( {displayedIdentityIsAgent ? ( - - ) : null} - + <> + + {/* Grouped with the timestamp so the two wrap together. */} + + + + + + ) : ( + + )}

{description.action} diff --git a/desktop/src/features/messages/ui/TimelineMessageList.tsx b/desktop/src/features/messages/ui/TimelineMessageList.tsx index 3c35cb6d43..a1a18f755e 100644 --- a/desktop/src/features/messages/ui/TimelineMessageList.tsx +++ b/desktop/src/features/messages/ui/TimelineMessageList.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { VList } from "virtua"; import type { VListHandle } from "virtua"; -import { formatDayHeading } from "@/features/messages/lib/dateFormatters"; +import { formatDayGroupLabel } from "@/shared/lib/datetime"; import { timelineRowReserveStyle } from "@/features/messages/lib/rowHeightEstimate"; import { buildTimelineDayGroups, @@ -349,13 +349,13 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({ data-day-label={ group.headingTimestamp === null ? undefined - : formatDayHeading(group.headingTimestamp) + : formatDayGroupLabel(group.headingTimestamp) } data-testid="message-timeline-day-group" key={group.key} > {group.headingTimestamp === null ? null : ( - + )} {group.items.map((item) => ( @@ -612,7 +612,7 @@ function VirtualizedTimelineRows({ return

{item.content}
; } if (item.kind === "day-divider") { - const dayLabel = formatDayHeading(item.headingTimestamp); + const dayLabel = formatDayGroupLabel(item.headingTimestamp); return (
{ + // Regression guard: this used to render a bare clock time, so a message from + // last week read "9:05 AM" once its day divider scrolled out of view. + assert.match( + source, + /: formatItemTimestamp\(createdAt, \{ withTime: true \}\)/, + ); +}); + +test("the continuation gutter stays clock-only", () => { + // 36px of width (w-9). A relative label would not fit. + assert.match( + source, + /hideDayPeriod \? formatTimeWithoutDayPeriod\(formatTime\(createdAt\)\)/, + ); +}); + +test("both labels derive from createdAt, not a pre-formatted prop", () => { + // A captured string would keep saying "Today" after midnight. + assert.doesNotMatch(source, /time: string/); + assert.doesNotMatch(source, /\btime\b(?!\w)[^;]*?=\s*\{/); +}); + +test("the tooltip still carries the unabbreviated timestamp", () => { + assert.match(source, /formatFullDateTime\(createdAt\)/); +}); diff --git a/desktop/src/shared/lib/datetime.test.mjs b/desktop/src/shared/lib/datetime.test.mjs new file mode 100644 index 0000000000..4f43551926 --- /dev/null +++ b/desktop/src/shared/lib/datetime.test.mjs @@ -0,0 +1,185 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { formatDayGroupLabel, formatItemTimestamp } from "./datetime.ts"; + +/** Local-time unix seconds, so the tests read in the same zone the code uses. */ +function at(year, monthIndex, day, hour = 12, minute = 0) { + return new Date(year, monthIndex, day, hour, minute).getTime() / 1_000; +} + +const NOW = at(2026, 6, 30, 14, 30); // Thu Jul 30 2026, 2:30 PM local + +test("the same calendar day reads Today", () => { + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 9, 5), NOW), "Today"); + // Just after midnight and just before it still count as the same day. + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 0, 1), NOW), "Today"); + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 23, 59), NOW), "Today"); +}); + +test("the previous calendar day reads Yesterday", () => { + assert.equal(formatDayGroupLabel(at(2026, 6, 29, 23, 59), NOW), "Yesterday"); + assert.equal(formatDayGroupLabel(at(2026, 6, 29, 0, 0), NOW), "Yesterday"); +}); + +test("Yesterday is a calendar boundary, not a 24-hour window", () => { + // 15 hours earlier, but the day rolled over: yesterday, not Today. + const lateLastNight = at(2026, 6, 29, 23, 30); + assert.equal(formatDayGroupLabel(lateLastNight, NOW), "Yesterday"); + // 22 hours earlier and still the same calendar day: Today. + const earlyToday = at(2026, 6, 30, 0, 30); + assert.equal(formatDayGroupLabel(earlyToday, NOW), "Today"); +}); + +test("two to six days back reads as the weekday alone", () => { + assert.equal(formatDayGroupLabel(at(2026, 6, 28), NOW), "Tuesday"); + assert.equal(formatDayGroupLabel(at(2026, 6, 27), NOW), "Monday"); + assert.equal(formatDayGroupLabel(at(2026, 6, 24), NOW), "Friday"); +}); + +test("seven days back leaves the weekday band, so it can't repeat a name", () => { + // Same weekday name as today — a weekday label here would be ambiguous. + assert.equal(formatDayGroupLabel(at(2026, 6, 23), NOW), "July 23"); +}); + +test("older dates in the current year omit the year", () => { + assert.equal(formatDayGroupLabel(at(2026, 5, 20), NOW), "June 20"); + assert.equal(formatDayGroupLabel(at(2026, 0, 3), NOW), "January 3"); +}); + +test("dates in earlier years include the year", () => { + assert.equal(formatDayGroupLabel(at(2025, 5, 20), NOW), "June 20, 2025"); + assert.equal(formatDayGroupLabel(at(2022, 7, 22), NOW), "August 22, 2022"); +}); + +test("old dates keep the day, so consecutive dividers stay distinguishable", () => { + // The standard would collapse these to "Aug 2022"; a group label must + // identify its own day. + const labels = [ + formatDayGroupLabel(at(2022, 7, 21), NOW), + formatDayGroupLabel(at(2022, 7, 22), NOW), + formatDayGroupLabel(at(2022, 7, 23), NOW), + ]; + assert.equal(new Set(labels).size, 3, `labels repeated: ${labels}`); +}); + +test("no label carries an ordinal suffix", () => { + const probes = [ + at(2026, 6, 30), + at(2026, 6, 29), + at(2026, 6, 27), + at(2026, 5, 1), + at(2026, 5, 2), + at(2026, 5, 3), + at(2026, 5, 11), + at(2026, 5, 12), + at(2026, 5, 13), + at(2026, 5, 21), + at(2026, 5, 22), + at(2026, 5, 23), + at(2025, 5, 20), + ]; + for (const probe of probes) { + assert.doesNotMatch( + formatDayGroupLabel(probe, NOW), + /\d(?:st|nd|rd|th)\b/, + `ordinal suffix in ${formatDayGroupLabel(probe, NOW)}`, + ); + } +}); + +test("a future timestamp is not labelled with a past weekday", () => { + // Clock skew, or a relay running ahead. "Tuesday" would read as last Tuesday. + const nextWeek = at(2026, 7, 4); + assert.equal(formatDayGroupLabel(nextWeek, NOW), "August 4"); + // Still same-day, so Today remains correct for small skew. + assert.equal(formatDayGroupLabel(at(2026, 6, 30, 23, 0), NOW), "Today"); +}); + +test("the label follows the current clock, not a captured one", () => { + const event = at(2026, 6, 30, 9, 0); + assert.equal(formatDayGroupLabel(event, NOW), "Today"); + // Same event, read a day later. + assert.equal(formatDayGroupLabel(event, at(2026, 6, 31, 9, 0)), "Yesterday"); +}); + +// ── formatItemTimestamp ───────────────────────────────────────────────────── + +test("today is a bare clock time in both modes", () => { + const today = at(2026, 6, 30, 9, 5); + assert.equal(formatItemTimestamp(today, { nowSeconds: NOW }), "9:05 AM"); + assert.equal( + formatItemTimestamp(today, { withTime: true, nowSeconds: NOW }), + "9:05 AM", + ); +}); + +test("compact mode drops the time outside today", () => { + const opts = { nowSeconds: NOW }; + assert.equal(formatItemTimestamp(at(2026, 6, 29, 9, 5), opts), "Yesterday"); + assert.equal(formatItemTimestamp(at(2026, 6, 27, 9, 5), opts), "Monday"); + assert.equal(formatItemTimestamp(at(2026, 5, 20, 9, 5), opts), "Jun 20"); + assert.equal( + formatItemTimestamp(at(2025, 5, 20, 9, 5), opts), + "Jun 20, 2025", + ); +}); + +test("roomy mode keeps the time at every band, joined with 'at'", () => { + const opts = { withTime: true, nowSeconds: NOW }; + assert.equal( + formatItemTimestamp(at(2026, 6, 29, 9, 5), opts), + "Yesterday at 9:05 AM", + ); + assert.equal( + formatItemTimestamp(at(2026, 6, 27, 14, 34), opts), + "Monday at 2:34 PM", + ); + assert.equal( + formatItemTimestamp(at(2026, 5, 20, 14, 34), opts), + "Jun 20 at 2:34 PM", + ); + assert.equal( + formatItemTimestamp(at(2025, 5, 20, 14, 34), opts), + "Jun 20, 2025 at 2:34 PM", + ); +}); + +test("the year is omitted within the current year in both modes", () => { + for (const withTime of [false, true]) { + const label = formatItemTimestamp(at(2026, 0, 3, 9, 5), { + withTime, + nowSeconds: NOW, + }); + assert.doesNotMatch(label, /2026/, `current year leaked into "${label}"`); + } +}); + +test("no item label carries an ordinal suffix", () => { + for (const withTime of [false, true]) { + for (const day of [1, 2, 3, 11, 12, 13, 21, 22, 23, 31]) { + const label = formatItemTimestamp(at(2026, 0, day, 9, 5), { + withTime, + nowSeconds: NOW, + }); + assert.doesNotMatch( + label, + /\d(?:st|nd|rd|th)\b/, + `ordinal in "${label}"`, + ); + } + } +}); + +test("compact labels stay short enough for a narrow list row", () => { + for (const probe of [ + at(2026, 6, 30, 14, 34), + at(2026, 6, 29), + at(2026, 6, 27), + at(2026, 5, 20), + at(2025, 5, 20), + ]) { + const label = formatItemTimestamp(probe, { nowSeconds: NOW }); + assert.ok(label.length <= 12, `"${label}" is ${label.length} chars`); + } +}); diff --git a/desktop/src/shared/lib/datetime.ts b/desktop/src/shared/lib/datetime.ts new file mode 100644 index 0000000000..65b8944943 --- /dev/null +++ b/desktop/src/shared/lib/datetime.ts @@ -0,0 +1,162 @@ +/** + * Relative date labels shared by the chat timeline and the Inbox. + * + * Both surfaces group items by calendar day and label the group. They used to + * do it independently — chat via `messages/lib/dateFormatters.formatDayHeading`, + * the Inbox inline inside `groupInboxItems` — and the two drifted apart: chat + * appended an ordinal ("Monday, March 31st") while the Inbox always printed the + * year ("Jul 8, 2026", even for a date three weeks ago). + * + * The ladder follows the Block writing standard for relative dates, with one + * deliberate deviation noted on `formatDayGroupLabel`. + */ + +const WEEKDAY_FORMATTER = new Intl.DateTimeFormat("en-US", { + weekday: "long", +}); + +const MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", +}); + +const MONTH_DAY_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "long", + day: "numeric", + year: "numeric", +}); + +const SHORT_MONTH_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +const SHORT_MONTH_DAY_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +const TIME_FORMATTER = new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", +}); + +/** Days in a week, past which the weekday name stops being unambiguous. */ +const WEEKDAY_BAND_DAYS = 7; + +/** + * Label for a group of items that share a calendar day — a chat day divider or + * an Inbox section header. + * + * ``` + * Today → "Today" + * Yesterday → "Yesterday" + * 2–6 days ago → "Monday" + * older, this year → "June 20" + * earlier years → "June 20, 2025" + * ``` + * + * Deliberate deviation from the standard: the standard collapses anything over + * ten months old to month and year ("Aug 2022"). A group label has to *identify* + * its day — collapsing would give every day in a month the same header, so + * scrolling old history would show a run of identical dividers with no way to + * tell one day from the next. The day is kept and only the year is conditional. + * + * No ordinal suffix ("June 20", never "June 20th"), per the standard. + * + * `nowSeconds` is injectable so the relative bands are testable; it must stay a + * parameter rather than a captured constant, because a label rendered before + * midnight has to say "Yesterday" once the day rolls over. + */ +export function formatDayGroupLabel( + unixSeconds: number, + nowSeconds = Date.now() / 1_000, +): string { + const date = new Date(unixSeconds * 1_000); + const now = new Date(nowSeconds * 1_000); + const dayDiff = calendarDaysBetween(now, date); + + if (dayDiff === 0) return "Today"; + if (dayDiff === 1) return "Yesterday"; + // Bounded below as well as above: a timestamp in the future (clock skew, or a + // relay ahead of this machine) must not be labelled with a weekday that reads + // as the recent past. + if (dayDiff > 1 && dayDiff < WEEKDAY_BAND_DAYS) { + return WEEKDAY_FORMATTER.format(date); + } + + return date.getFullYear() === now.getFullYear() + ? MONTH_DAY_FORMATTER.format(date) + : MONTH_DAY_YEAR_FORMATTER.format(date); +} + +/** + * Label for a single item's timestamp — an Inbox list row, or a message in the + * Inbox thread pane. + * + * ``` + * withTime: false (narrow rows) withTime: true (roomy rows) + * Today → "2:34 PM" Today → "2:34 PM" + * Yest. → "Yesterday" Yest. → "Yesterday at 2:34 PM" + * 2–6d → "Monday" 2–6d → "Monday at 2:34 PM" + * year → "Jun 20" year → "Jun 20 at 2:34 PM" + * older → "Jun 20, 2025" older → "Jun 20, 2025 at 2:34 PM" + * ``` + * + * Today needs no date word in either mode: a bare clock time already reads as + * today, and "Today at 2:34 PM" is longer without saying more. + * + * `withTime` is a surface decision, not a preference. Somewhere you read + * conversation, the time is part of the content, so pass `true`. In a narrow + * list row it costs more width than it earns and the full timestamp is a hover + * away, so pass `false`. + * + * Months are abbreviated here but spelled out in `formatDayGroupLabel` — a + * day divider is a roomy header of its own, an item label shares a row with a + * name, a channel, and a preview. + */ +export function formatItemTimestamp( + unixSeconds: number, + { + withTime = false, + nowSeconds = Date.now() / 1_000, + }: { withTime?: boolean; nowSeconds?: number } = {}, +): string { + const date = new Date(unixSeconds * 1_000); + const now = new Date(nowSeconds * 1_000); + const dayDiff = calendarDaysBetween(now, date); + const time = TIME_FORMATTER.format(date); + + if (dayDiff === 0) return time; + + let dayLabel: string; + if (dayDiff === 1) { + dayLabel = "Yesterday"; + } else if (dayDiff > 1 && dayDiff < WEEKDAY_BAND_DAYS) { + dayLabel = WEEKDAY_FORMATTER.format(date); + } else { + dayLabel = + date.getFullYear() === now.getFullYear() + ? SHORT_MONTH_DAY_FORMATTER.format(date) + : SHORT_MONTH_DAY_YEAR_FORMATTER.format(date); + } + + return withTime ? `${dayLabel} at ${time}` : dayLabel; +} + +/** Local midnight of the calendar day containing `date`. */ +function startOfLocalDay(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), date.getDate()); +} + +/** + * Whole calendar days from `date` to `now`, in local time. Rounded rather than + * floored so a DST transition — a 23- or 25-hour day — still counts as one day. + */ +function calendarDaysBetween(now: Date, date: Date): number { + return Math.round( + (startOfLocalDay(now).getTime() - startOfLocalDay(date).getTime()) / + 86_400_000, + ); +} From ba3747175576517d1cd1b11d6274d80d26a577f5 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 31 Jul 2026 10:10:46 -0400 Subject: [PATCH 2/3] fix(mobile): match the desktop relative date ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile day dividers went Today / Yesterday / "Tuesday, March 31, 2026" — no weekday-only band, and the year printed even for a date three weeks old. Desktop's ladder adds the 2–6 day weekday band and makes the year conditional, so `formatDayHeading` now mirrors `formatDayGroupLabel` exactly, including its two documented departures from the standard. The thread summary fallback also still read "on May 19th". Ordinals came out of desktop for the reason the standard gives; dropping the suffix here removes the last one and retires `_ordinalSuffix`. Day comparison moved to a rounded start-of-day difference, so a DST transition still counts as one calendar day rather than zero. Message timestamps stay clock-only and say why at the definition. They sit inside a chat bubble on a narrow screen with the divider a short scroll away, so mobile is the compact side of the surface split rather than a gap to close. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Clay Delk --- .../features/channels/date_formatters.dart | 83 +++++++++++------ .../channels/date_formatters_test.dart | 92 ++++++++++++++++--- 2 files changed, 131 insertions(+), 44 deletions(-) diff --git a/mobile/lib/features/channels/date_formatters.dart b/mobile/lib/features/channels/date_formatters.dart index 2378c305fb..11f8773d1e 100644 --- a/mobile/lib/features/channels/date_formatters.dart +++ b/mobile/lib/features/channels/date_formatters.dart @@ -4,11 +4,32 @@ import 'package:intl/intl.dart'; // Re-export shortPubkey so existing callers continue to compile. export '../../shared/utils/string_utils.dart' show shortPubkey; -final _fullDateFormat = DateFormat('EEEE, MMMM d, y'); -final _shortMonthFormat = DateFormat('MMM'); +final _weekdayFormat = DateFormat('EEEE'); +final _monthDayFormat = DateFormat('MMMM d'); +final _monthDayYearFormat = DateFormat('MMMM d, y'); +final _shortMonthDayFormat = DateFormat('MMM d'); final _messageTimeFormat = DateFormat('h:mm a', 'en_US'); -/// Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026". +/// Days in a week, past which the weekday name stops being unambiguous. +const _weekdayBandDays = 7; + +/// Label for a day divider: "Today", "Yesterday", "Monday", "March 31", or +/// "March 31, 2025". +/// +/// ``` +/// Today → "Today" +/// Yesterday → "Yesterday" +/// 2–6 days ago → "Monday" +/// older, this year → "March 31" +/// earlier years → "March 31, 2025" +/// ``` +/// +/// Mirrors `formatDayGroupLabel` in `desktop/src/shared/lib/datetime.ts`, +/// including its two departures from the Block writing standard: the day is +/// kept in the oldest band rather than collapsing to month-and-year, because a +/// divider has to *identify* its day — collapsing would give every day in a +/// month the same header. And there is no ordinal suffix ("March 31", never +/// "March 31st"), which the standard does ask for. /// /// [now] is exposed for testing; production callers should omit it. String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) { @@ -17,21 +38,28 @@ String formatDayHeading(int unixSeconds, {@visibleForTesting DateTime? now}) { isUtc: true, ).toLocal(); now ??= DateTime.now(); - final today = DateTime(now.year, now.month, now.day); - final messageDay = DateTime(date.year, date.month, date.day); + final dayDiff = _calendarDaysBetween(now, date); - if (today.year == messageDay.year && - today.month == messageDay.month && - today.day == messageDay.day) { - return 'Today'; + if (dayDiff == 0) return 'Today'; + if (dayDiff == 1) return 'Yesterday'; + // Bounded below as well as above: a timestamp in the future (clock skew, or a + // relay ahead of this device) must not be labelled with a weekday that reads + // as the recent past. + if (dayDiff > 1 && dayDiff < _weekdayBandDays) { + return _weekdayFormat.format(date); } - final yesterday = DateTime(now.year, now.month, now.day - 1); - if (yesterday.year == messageDay.year && - yesterday.month == messageDay.month && - yesterday.day == messageDay.day) { - return 'Yesterday'; - } - return _fullDateFormat.format(date); + + return date.year == now.year + ? _monthDayFormat.format(date) + : _monthDayYearFormat.format(date); +} + +/// Whole calendar days from [date] to [now], in local time. Rounded rather than +/// truncated so a DST transition — a 23- or 25-hour day — still counts as one. +int _calendarDaysBetween(DateTime now, DateTime date) { + final startOfNow = DateTime(now.year, now.month, now.day); + final startOfDate = DateTime(date.year, date.month, date.day); + return (startOfNow.difference(startOfDate).inHours / 24).round(); } /// Whether two unix-second timestamps fall on the same calendar day (local time). @@ -65,7 +93,7 @@ String relativeTime(int unixSeconds) { } /// Returns desktop-parity thread activity copy such as "just now", -/// "3 hours ago", or "on May 19th". +/// "3 hours ago", or "on May 19". String formatThreadSummaryLastReplyTime( int unixSeconds, { @visibleForTesting int? nowSeconds, @@ -83,25 +111,20 @@ String formatThreadSummaryLastReplyTime( unixSeconds * 1000, isUtc: true, ).toLocal(); - return 'on ${_shortMonthFormat.format(date)} ' - '${date.day}${_ordinalSuffix(date.day)}'; + // No ordinal suffix, per the writing standard. + return 'on ${_shortMonthDayFormat.format(date)}'; } String _formatAgo(int value, String unit) => '$value $unit${value == 1 ? '' : 's'} ago'; -String _ordinalSuffix(int day) { - final lastTwoDigits = day % 100; - if (lastTwoDigits >= 11 && lastTwoDigits <= 13) return 'th'; - return switch (day % 10) { - 1 => 'st', - 2 => 'nd', - 3 => 'rd', - _ => 'th', - }; -} - /// Desktop-parity message clock time, e.g. "2:34 PM". +/// +/// Deliberately clock-only at every band, unlike desktop's message header, +/// which reads "Yesterday at 2:34 PM". Mobile timestamps sit inside a chat +/// bubble on a narrow screen with the day divider a short scroll away, so this +/// is the compact side of that split — not an oversight. Change it only +/// alongside a layout that has room for a date. String formatMessageTime(int unixSeconds) { final date = DateTime.fromMillisecondsSinceEpoch( unixSeconds * 1000, diff --git a/mobile/test/features/channels/date_formatters_test.dart b/mobile/test/features/channels/date_formatters_test.dart index 42363699f7..07f9ca4aca 100644 --- a/mobile/test/features/channels/date_formatters_test.dart +++ b/mobile/test/features/channels/date_formatters_test.dart @@ -19,9 +19,55 @@ void main() { expect(formatDayHeading(yesterday, now: now), 'Yesterday'); }); - test('older date returns full formatted date', () { - final older = _ts(DateTime(2026, 3, 31, 12, 0)); - expect(formatDayHeading(older, now: now), 'Tuesday, March 31, 2026'); + test('two to six days back names the weekday alone', () { + expect( + formatDayHeading(_ts(DateTime(2026, 4, 21, 9)), now: now), + 'Tuesday', + ); + expect( + formatDayHeading(_ts(DateTime(2026, 4, 18, 9)), now: now), + 'Saturday', + ); + }); + + test('a week back switches to a date, without the current year', () { + // Seven days is the first day a weekday name stops being unambiguous. + expect( + formatDayHeading(_ts(DateTime(2026, 4, 16, 9)), now: now), + 'April 16', + ); + expect( + formatDayHeading(_ts(DateTime(2026, 3, 31, 12)), now: now), + 'March 31', + ); + }); + + test('an earlier year keeps the year', () { + expect( + formatDayHeading(_ts(DateTime(2025, 3, 31, 12)), now: now), + 'March 31, 2025', + ); + }); + + test('no divider carries an ordinal suffix', () { + // 1/2/3 and the 11/12/13 exceptions are where ordinals used to appear. + for (final day in [1, 2, 3, 11, 12, 13, 21, 22, 23, 31]) { + expect( + formatDayHeading(_ts(DateTime(2025, 1, day, 12)), now: now), + isNot(matches(RegExp(r'\d(st|nd|rd|th)'))), + ); + } + }); + + test('the oldest band still tells consecutive days apart', () { + // The reason the day is kept rather than collapsed to "Jan 2022". + final labels = [3, 4, 5] + .map( + (day) => + formatDayHeading(_ts(DateTime(2022, 1, day, 12)), now: now), + ) + .toSet(); + expect(labels, hasLength(3)); }); test('midnight boundary: 11:59 PM today vs 12:01 AM tomorrow', () { @@ -29,11 +75,8 @@ void main() { final earlyTomorrow = _ts(DateTime(2026, 4, 24, 0, 1)); expect(formatDayHeading(lateTonight, now: now), 'Today'); - // Tomorrow relative to our fixed "now" is not today or yesterday. - expect( - formatDayHeading(earlyTomorrow, now: now), - 'Friday, April 24, 2026', - ); + // A future timestamp must not borrow a weekday that reads as the past. + expect(formatDayHeading(earlyTomorrow, now: now), 'April 24'); }); test('cross-month boundary: April 1 → March 31 is yesterday', () { @@ -41,6 +84,26 @@ void main() { final march31 = _ts(DateTime(2026, 3, 31, 20, 0)); expect(formatDayHeading(march31, now: april1), 'Yesterday'); }); + + test('bands are calendar days, not 24-hour windows', () { + // 15 hours old but across midnight, so "Yesterday"; 22 hours old on the + // same calendar day, so "Today". + final elevenPmYesterday = DateTime(2026, 4, 23, 2, 0); + expect( + formatDayHeading( + _ts(DateTime(2026, 4, 22, 23, 0)), + now: elevenPmYesterday, + ), + 'Yesterday', + ); + expect( + formatDayHeading( + _ts(DateTime(2026, 4, 23, 1, 0)), + now: DateTime(2026, 4, 23, 23, 0), + ), + 'Today', + ); + }); }); group('isSameDay', () { @@ -79,23 +142,24 @@ void main() { ); }); - test('uses a short month and ordinal for older replies', () { + test('dates older replies with a short month and no ordinal', () { final may19 = _ts(DateTime(2026, 5, 19, 12)); - final may27 = _ts(DateTime(2026, 5, 27, 12)); + final may1 = _ts(DateTime(2026, 5, 1, 12)); expect( formatThreadSummaryLastReplyTime( may19, nowSeconds: _ts(DateTime(2026, 5, 27, 12)), ), - 'on May 19th', + 'on May 19', ); + // The 1st is where an ordinal is most tempting. expect( formatThreadSummaryLastReplyTime( - may27, - nowSeconds: _ts(DateTime(2026, 6, 4, 12)), + may1, + nowSeconds: _ts(DateTime(2026, 5, 27, 12)), ), - 'on May 27th', + 'on May 1', ); }); }); From a938de1f74cb25ad91e72fd59e75b80be0901a83 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Fri, 31 Jul 2026 16:20:30 -0400 Subject: [PATCH 3/3] fix(desktop): align the agent owner chip with the message header baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chip's robot glyph sat a couple of pixels above the text beside it, and the two branches of the component aligned differently — the "owner unavailable" variant centred its contents on the line box while the "managed by" variant baseline-aligned them with a 1px nudge on the icon, so the same chip read differently depending on whether an owner was known. Flatten both branches into the chip's own baseline row so the label sets the baseline, share one icon between them, and drop the icon's optical centre onto the text's cap band with a 0.125em transform. Measured against the author name: the label baseline now matches exactly, and the icon's ink centre lands within 0.15px of the cap band on both variants instead of 2.6-3.6px above it. The new middot in this branch draws the eye straight to that row, which is what surfaced it. Co-Authored-By: Claude Opus 5 (1M context) Signed-off-by: Clay Delk --- .../messages/ui/MessageAgentOwner.tsx | 34 ++++++++++++------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/desktop/src/features/messages/ui/MessageAgentOwner.tsx b/desktop/src/features/messages/ui/MessageAgentOwner.tsx index e5b92cd734..e394d567b4 100644 --- a/desktop/src/features/messages/ui/MessageAgentOwner.tsx +++ b/desktop/src/features/messages/ui/MessageAgentOwner.tsx @@ -17,14 +17,28 @@ export function MessageAgentOwner({ {ownerLabel ? "Agent managed by" : "Agent; owner unavailable"} + {/* + * Icon and label sit directly in this baseline row rather than in a nested + * flex wrapper, so the label's own baseline is what aligns with the author + * name beside it. Both branches share the icon for the same reason: two + * wrappers meant two alignment rules and the "owner unavailable" variant + * had drifted a pixel off the other one. + * + * `self-center` keeps the icon out of baseline alignment, so the label — + * not the icon's box — sets this chip's baseline. Centred on the line box + * the glyph's ink still rides ~1.6px above the text's cap band, reading as + * a couple of pixels too high; 0.125em drops its optical centre onto that + * band. In em so it holds under Cmd +/- zoom, and as a transform so it + * shifts nothing else in the row. + */} +