diff --git a/desktop/src/features/channels/lib/threadFocusLayout.ts b/desktop/src/features/channels/lib/threadFocusLayout.ts index f14f3399b..55e2c0592 100644 --- a/desktop/src/features/channels/lib/threadFocusLayout.ts +++ b/desktop/src/features/channels/lib/threadFocusLayout.ts @@ -24,7 +24,7 @@ export const THREAD_FOCUS_SLIVER_WIDTH_PX = 72; * with auto horizontal margins so the reading measure stays comfortable no * matter how wide the window gets. */ -export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 880; +export const THREAD_FOCUS_COLUMN_MAX_WIDTH_PX = 760; /** * Horizontal distance the focus drawer travels on enter/exit. diff --git a/desktop/src/features/messages/lib/dateFormatters.test.mjs b/desktop/src/features/messages/lib/dateFormatters.test.mjs index f579cbfcf..6dd55cb33 100644 --- a/desktop/src/features/messages/lib/dateFormatters.test.mjs +++ b/desktop/src/features/messages/lib/dateFormatters.test.mjs @@ -3,6 +3,7 @@ import test from "node:test"; import { formatDayHeading, + formatMessageTimestamp, formatShortMonthDayOrdinal, formatThreadSummaryLastReplyTime, formatTimeWithoutDayPeriod, @@ -81,6 +82,37 @@ test("formatTimeWithoutDayPeriod removes AM/PM suffixes", () => { assert.equal(formatTimeWithoutDayPeriod("16:20"), "16:20"); }); +test("formatMessageTimestamp uses compact relative units for recent messages", () => { + const now = new Date(2026, 7, 6, 21, 0).getTime() / 1_000; + + assert.equal(formatMessageTimestamp(now - 30, now), "Just now"); + assert.equal(formatMessageTimestamp(now - 60, now), "1m ago"); + assert.equal(formatMessageTimestamp(now - 59 * 60, now), "59m ago"); + assert.equal(formatMessageTimestamp(now - 60 * 60, now), "1h ago"); + assert.equal(formatMessageTimestamp(now - 23 * 60 * 60, now), "23h ago"); +}); + +test("formatMessageTimestamp uses weekday and time for the previous six calendar days", () => { + const now = new Date(2026, 7, 6, 21, 0).getTime() / 1_000; + const yesterday = new Date(2026, 7, 5, 20, 15).getTime() / 1_000; + const sixDaysAgo = new Date(2026, 6, 31, 9, 30).getTime() / 1_000; + + assert.equal(formatMessageTimestamp(yesterday, now), "Wednesday at 8:15 PM"); + assert.equal(formatMessageTimestamp(sixDaysAgo, now), "Friday at 9:30 AM"); +}); + +test("formatMessageTimestamp uses short dates after six calendar days", () => { + const now = new Date(2026, 7, 6, 21, 0).getTime() / 1_000; + const sevenDaysAgo = new Date(2026, 6, 30, 9, 30).getTime() / 1_000; + const previousYear = new Date(2025, 11, 15, 14, 5).getTime() / 1_000; + + assert.equal(formatMessageTimestamp(sevenDaysAgo, now), "Jul 30 at 9:30 AM"); + assert.equal( + formatMessageTimestamp(previousYear, now), + "Dec 15, 2025 at 2:05 PM", + ); +}); + test("formatThreadSummaryLastReplyTime expands relative units", () => { const now = localUnixSeconds(2026, 4, 19); diff --git a/desktop/src/features/messages/lib/dateFormatters.ts b/desktop/src/features/messages/lib/dateFormatters.ts index 04c85d815..d309d8d49 100644 --- a/desktop/src/features/messages/lib/dateFormatters.ts +++ b/desktop/src/features/messages/lib/dateFormatters.ts @@ -37,6 +37,17 @@ const SHORT_MONTH_FORMATTER = new Intl.DateTimeFormat("en-US", { month: "short", }); +const SHORT_DATE_FORMATTER = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +const SHORT_DATE_WITH_YEAR_FORMATTER = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", +}); + /** Short clock time, e.g. "2:34 PM". */ export function formatTime(unixSeconds: number): string { return TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); @@ -52,6 +63,39 @@ export function formatFullDateTime(unixSeconds: number): string { return FULL_DATE_TIME_FORMATTER.format(new Date(unixSeconds * 1_000)); } +/** + * Compact, recency-aware message timestamp. + * + * Recent messages use terse relative units ("Just now", "8m", "3h"). From + * the previous calendar day through six calendar days ago, use the weekday and + * clock time ("Wednesday at 2:34 PM"). Older messages use a short date and + * clock time, adding the year only when it differs from the current year. + */ +export function formatMessageTimestamp( + unixSeconds: number, + nowSeconds = Date.now() / 1_000, +): string { + const diffSeconds = Math.max(0, nowSeconds - unixSeconds); + if (diffSeconds < 60) return "Just now"; + if (diffSeconds < 3_600) return `${Math.floor(diffSeconds / 60)}m ago`; + if (diffSeconds < 86_400) return `${Math.floor(diffSeconds / 3_600)}h ago`; + + const date = new Date(unixSeconds * 1_000); + const now = new Date(nowSeconds * 1_000); + const calendarDayDiff = differenceInLocalCalendarDays(date, now); + const time = TIME_FORMATTER.format(date); + + if (calendarDayDiff >= 1 && calendarDayDiff <= 6) { + return `${WEEKDAY_FORMATTER.format(date)} at ${time}`; + } + + const dateLabel = + date.getFullYear() === now.getFullYear() + ? SHORT_DATE_FORMATTER.format(date) + : SHORT_DATE_WITH_YEAR_FORMATTER.format(date); + return `${dateLabel} at ${time}`; +} + /** * Human-friendly day label for dividers and sticky headers. * Returns "Today", "Yesterday", a current-year date like "Monday, March 31st", @@ -131,6 +175,20 @@ function isSameDayDate(a: Date, b: Date): boolean { ); } +function differenceInLocalCalendarDays(earlier: Date, later: Date): number { + const earlierDay = new Date( + earlier.getFullYear(), + earlier.getMonth(), + earlier.getDate(), + ); + const laterDay = new Date( + later.getFullYear(), + later.getMonth(), + later.getDate(), + ); + return Math.round((laterDay.getTime() - earlierDay.getTime()) / 86_400_000); +} + function formatMonthDayOrdinal( date: Date, monthFormatter: Intl.DateTimeFormat, diff --git a/desktop/src/features/messages/lib/messageThreadPanelLayout.ts b/desktop/src/features/messages/lib/messageThreadPanelLayout.ts index 9467d5d3b..b7671e226 100644 --- a/desktop/src/features/messages/lib/messageThreadPanelLayout.ts +++ b/desktop/src/features/messages/lib/messageThreadPanelLayout.ts @@ -4,15 +4,15 @@ */ /** Inline gutter around thread message rows. */ -export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-2"; +export const THREAD_PANEL_MESSAGE_GUTTER_CLASS = "px-4"; /** Inline gutter around the thread composer and its activity row. */ export const THREAD_PANEL_COMPOSER_GUTTER_CLASS = "px-5"; /** * Centers the reading column when a `columnMaxWidthPx` is supplied (focus-mode - * drawer). `px-10` (40px) is the inline gutter between the column and the drawer - * edges; the max-width itself is applied inline since it is a caller-provided - * pixel value. + * drawer). The responsive gutter keeps compact focus drawers usable while + * preserving generous whitespace at desktop widths. The max-width itself is + * applied inline since it is a caller-provided value. */ -export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-10"; +export const THREAD_PANEL_COLUMN_CLASS = "mx-auto w-full px-6 sm:px-10"; diff --git a/desktop/src/features/messages/ui/MessageAgentOwner.tsx b/desktop/src/features/messages/ui/MessageAgentOwner.tsx index e5b92cd73..db4348bb2 100644 --- a/desktop/src/features/messages/ui/MessageAgentOwner.tsx +++ b/desktop/src/features/messages/ui/MessageAgentOwner.tsx @@ -1,5 +1,3 @@ -import { Bot } from "lucide-react"; - import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover"; export function MessageAgentOwner({ @@ -11,7 +9,7 @@ export function MessageAgentOwner({ }) { return ( @@ -19,30 +17,22 @@ export function MessageAgentOwner({ {ownerPubkey && ownerLabel ? ( <> -