feat(desktop): improve thread readability

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
morgmart
2026-08-07 09:36:22 -07:00
parent cc9a2f7833
commit e5ad3fd37f
11 changed files with 151 additions and 33 deletions
@@ -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.
@@ -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);
@@ -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,
@@ -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";
@@ -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 (
<span
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-xs leading-4 text-muted-foreground/65"
className="inline-flex min-w-0 max-w-56 items-baseline gap-1 text-2xs font-normal leading-4 text-muted-foreground"
data-testid="message-agent-owner"
>
<span className="sr-only">
@@ -19,30 +17,22 @@ export function MessageAgentOwner({
</span>
{ownerPubkey && ownerLabel ? (
<>
<span
aria-hidden="true"
className="inline-flex shrink-0 items-baseline gap-1 leading-4"
>
<Bot className="relative -top-px h-3.5 w-3.5 self-center" />
<span>managed by</span>
<span aria-hidden="true" className="shrink-0">
managed by
</span>
<UserProfilePopover
pubkey={ownerPubkey}
triggerAriaLabel={ownerLabel}
triggerElement="span"
>
<span className="min-w-0 truncate rounded font-semibold text-foreground/85 hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
<span className="min-w-0 truncate rounded font-medium text-muted-foreground hover:text-foreground hover:underline focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring">
{ownerLabel}
</span>
</UserProfilePopover>
</>
) : (
<span
aria-hidden="true"
className="inline-flex min-w-0 items-center gap-1"
>
<Bot className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">owner unavailable</span>
<span aria-hidden="true" className="truncate">
owner unavailable
</span>
)}
</span>
@@ -39,7 +39,7 @@ export function MessageAuthorText({
return (
<Component
className={cn(
"truncate text-sm font-bold leading-4 tracking-tight",
"truncate text-sm font-semibold leading-4",
hoverUnderline && "hover:underline",
className,
)}
@@ -246,7 +246,7 @@ export const MessageRow = React.memo(
message.body,
message.tags,
);
const bodyOffsetClass = emojiOnly ? "mt-1" : "-mt-0.5";
const bodyOffsetClass = "mt-1";
const { nonDmChannelNames: channelNames } = useChannelNavigation();
const openVideoReviewAt = useOpenVideoReviewAt();
@@ -367,7 +367,7 @@ export const MessageRow = React.memo(
<Markdown
channelNames={channelNames}
className={cn(
"max-w-full text-sm",
"max-w-full leading-relaxed [&>p+p]:mt-2 [&>ol]:space-y-1.5 [&>ul]:space-y-1.5",
emojiOnly &&
"text-4xl leading-tight [&_p]:leading-tight [&_img[data-custom-emoji]]:h-[1.45em] [&_img[data-custom-emoji]]:align-middle [&_button:has(img[data-custom-emoji])]:align-middle",
)}
@@ -823,7 +823,7 @@ export const MessageRow = React.memo(
className={cn(
"group/message relative z-10 rounded-2xl transition-colors",
playEntrance && "motion-enter-conversation",
"py-1",
isThreadReplyLayout ? "py-2" : "py-1.5",
hoverBackground
? "mx-1 px-2 hover:bg-muted/50 focus-within:bg-muted/50"
: isThreadReplyLayout
@@ -574,7 +574,7 @@ export function MessageThreadPanel({
</div>
) : (
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-1 pt-0")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-2 pt-1")}
data-testid="message-thread-head"
>
<div className="rounded-2xl">
@@ -631,7 +631,7 @@ export function MessageThreadPanel({
{showThreadHeadDivider ? (
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-2")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-3")}
data-testid="message-thread-head-divider"
>
<Separator className="bg-border/60" />
@@ -639,7 +639,7 @@ export function MessageThreadPanel({
) : null}
<div
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-3 pt-0")}
className={cn(THREAD_PANEL_MESSAGE_GUTTER_CLASS, "pb-4 pt-0")}
data-testid="message-thread-replies"
>
{threadRepliesPending && !isHuddleTranscript ? (
@@ -1,8 +1,10 @@
import {
formatFullDateTime,
formatMessageTimestamp,
formatTimeWithoutDayPeriod,
} from "@/features/messages/lib/dateFormatters";
import { cn } from "@/shared/lib/cn";
import { useMinuteNow } from "@/shared/lib/useMinuteNow";
import {
Tooltip,
TooltipContent,
@@ -23,7 +25,10 @@ export function MessageTimestamp({
hideDayPeriod?: boolean;
time: string;
}) {
const displayTime = hideDayPeriod ? formatTimeWithoutDayPeriod(time) : time;
const now = useMinuteNow();
const displayTime = hideDayPeriod
? formatTimeWithoutDayPeriod(time)
: formatMessageTimestamp(createdAt, now / 1_000);
return (
<TooltipProvider
@@ -34,7 +39,7 @@ export function MessageTimestamp({
<TooltipTrigger asChild>
<p
className={cn(
"shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55",
"shrink-0 cursor-default whitespace-nowrap text-2xs font-normal leading-4 tabular-nums text-muted-foreground",
className,
)}
data-testid="message-timestamp"
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
const MINUTE_MS = 60_000;
let currentMinute = Date.now();
let minuteTimer: ReturnType<typeof setInterval> | null = null;
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
if (minuteTimer === null) {
minuteTimer = setInterval(() => {
currentMinute = Date.now();
for (const notify of listeners) notify();
}, MINUTE_MS);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && minuteTimer !== null) {
clearInterval(minuteTimer);
minuteTimer = null;
}
};
}
function getSnapshot() {
return currentMinute;
}
/** A shared minute clock for recency labels rendered in large lists. */
export function useMinuteNow(): number {
return React.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}
+2 -2
View File
@@ -121,11 +121,11 @@ test("agent owner label identifies the agent and owner", async ({ page }) => {
.filter({ hasText: "Hey team — checking in." });
const ownerTreatment = aliceMessage.getByTestId("message-agent-owner");
await expect(ownerTreatment.locator("svg")).toBeVisible();
await expect(ownerTreatment.locator("svg")).toHaveCount(0);
await expect(
ownerTreatment.getByText("managed by", { exact: true }),
).toBeVisible();
await expect(ownerTreatment.locator(".font-semibold")).toHaveText("bob");
await expect(ownerTreatment.locator(".font-medium")).toHaveText("bob");
await expect(ownerTreatment.getByRole("button")).toHaveAccessibleName("bob");
await expect(ownerTreatment.locator(".sr-only")).toHaveText(
"Agent managed by",