Polish message timeline affordances

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-30 20:19:04 -04:00
parent 77a2e3b818
commit f214bb9d54
5 changed files with 22 additions and 102 deletions

View File

@@ -4,7 +4,7 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="flex items-center gap-2.5 py-1.5"
className="sticky top-0 z-30 flex items-center gap-2.5 bg-background/80 py-1 backdrop-blur-sm"
data-testid="message-timeline-day-divider"
data-day-label={label}
>

View File

@@ -244,28 +244,23 @@ export const MessageComposerToolbar = React.memo(
<div className="flex items-center gap-2">
{extraActions}
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label={isSending ? "Sending" : "Send message"}
className="rounded-full"
data-testid="send-message"
disabled={sendDisabled || isSending}
size="icon"
type="submit"
>
{isSending ? (
<span
aria-hidden
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
/>
) : (
<ArrowUp aria-hidden className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Send (Enter)</TooltipContent>
</Tooltip>
<Button
aria-label={isSending ? "Sending" : "Send message"}
className="rounded-full"
data-testid="send-message"
disabled={sendDisabled || isSending}
size="icon"
type="submit"
>
{isSending ? (
<span
aria-hidden
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
/>
) : (
<ArrowUp aria-hidden className="h-4 w-4" />
)}
</Button>
</div>
</div>
);

View File

@@ -40,14 +40,15 @@ export function MessageThreadSummaryRow({
summary: TimelineThreadSummary;
}) {
const visibleDepth = Math.min(Math.max(depth, 0), 6);
const marginLeftPx = visibleDepth * 28;
const messageTextOffsetPx = 50;
const marginLeftPx = visibleDepth * 28 + messageTextOffsetPx;
const depthGuideOffsets = Array.from(
{ length: visibleDepth },
(_, index) => 14 + index * 28,
);
return (
<div className="relative">
<div className="relative pb-2">
{depthGuideOffsets.length > 0 ? (
<div
aria-hidden
@@ -68,7 +69,7 @@ export function MessageThreadSummaryRow({
) : null}
<button
className="-mt-0.5 flex w-fit max-w-full items-center gap-1 rounded-xl px-3 py-0.5 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
className="-mt-0.5 inline-flex w-fit max-w-full items-center gap-1 rounded-full border border-border/70 bg-muted/70 py-0.5 pl-0.5 pr-2 text-left text-xs font-medium text-foreground/90 transition-colors hover:bg-accent hover:text-accent-foreground"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}

View File

@@ -10,7 +10,6 @@ import { TooltipProvider } from "@/shared/ui/tooltip";
import { TimelineSkeleton } from "./TimelineSkeleton";
import { TimelineMessageList } from "./TimelineMessageList";
import { useLoadOlderOnScroll } from "./useLoadOlderOnScroll";
import { useStickyDayHeader } from "./useStickyDayHeader";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
type MessageTimelineProps = {
@@ -121,21 +120,9 @@ export const MessageTimeline = React.memo(function MessageTimeline({
sentinelRef: topSentinelRef,
});
const stickyDayLabel = useStickyDayHeader(scrollContainerRef);
return (
<TooltipProvider delayDuration={200}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{stickyDayLabel && !isAtBottom ? (
<div
className="pointer-events-none absolute inset-x-0 top-16 z-10 flex justify-center px-4 pt-1 sm:px-6"
data-testid="message-timeline-sticky-day"
>
<p className="rounded-md border border-border/35 bg-background/45 px-2 py-0.5 text-[10px] font-medium leading-none tracking-[0.02em] text-muted-foreground/65 backdrop-blur-sm">
{stickyDayLabel}
</p>
</div>
) : null}
<div
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-12 pt-1 [overflow-anchor:none] sm:px-6"
data-scroll-restoration-id="message-timeline"

View File

@@ -1,63 +0,0 @@
import * as React from "react";
const STICKY_DAY_TRIGGER_OFFSET_PX = 64;
/**
* Tracks the day label of the topmost visible day-divider in the scroll
* container so a floating header can mirror it.
*
* Returns `null` when the first divider is still fully visible (no header
* needed) or when there are no dividers in the DOM.
*/
export function useStickyDayHeader(
scrollContainerRef: React.RefObject<HTMLDivElement | null>,
) {
const [label, setLabel] = React.useState<string | null>(null);
const update = React.useCallback(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
const dividers = container.querySelectorAll<HTMLElement>(
"[data-testid='message-timeline-day-divider']",
);
if (dividers.length === 0) {
setLabel(null);
return;
}
const containerTop = container.getBoundingClientRect().top;
const stickyTriggerTop = containerTop + STICKY_DAY_TRIGGER_OFFSET_PX;
// Walk dividers from the end — the last one whose top has reached the
// floating label's visual zone is the "current" day.
let current: string | null = null;
for (let i = dividers.length - 1; i >= 0; i--) {
const rect = dividers[i].getBoundingClientRect();
if (rect.top <= stickyTriggerTop) {
current = dividers[i].getAttribute("data-day-label");
break;
}
}
setLabel(current);
}, [scrollContainerRef]);
React.useEffect(() => {
const container = scrollContainerRef.current;
if (!container) {
return;
}
container.addEventListener("scroll", update, { passive: true });
// Run once on mount so the header appears if already scrolled.
update();
return () => {
container.removeEventListener("scroll", update);
};
}, [scrollContainerRef, update]);
return label;
}