fix(desktop): polish timeline spacing and panel chrome

Align sticky day headers, thread summaries, and composer offsets so message content stays visible and date transitions happen at the right moment. Soften side-sheet headers and markdown code blocks while keeping thread conversations auto-scrolled as new content arrives.

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-16 11:41:30 -04:00
parent 17abe0cb0d
commit 12467417fe
11 changed files with 226 additions and 167 deletions

View File

@@ -43,12 +43,12 @@ export function PersonaCatalogDetailsSheet({
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="w-full overflow-y-auto sm:max-w-xl"
className="flex w-full flex-col gap-0 overflow-hidden bg-background p-0 sm:max-w-xl"
data-testid="persona-catalog-details-sheet"
>
{persona ? (
<div className="space-y-6 pr-4">
<SheetHeader className="border-b border-border/60 pb-4 pr-10">
<>
<SheetHeader className="relative z-10 space-y-4 bg-background/25 px-6 py-6 pr-16 text-left shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
<div className="flex items-start gap-3">
<ProfileAvatar
avatarUrl={persona.avatarUrl}
@@ -68,93 +68,94 @@ export function PersonaCatalogDetailsSheet({
</div>
</div>
</SheetHeader>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-6">
<button
aria-label={getPersonaCatalogSelectionAriaLabel(
persona.displayName,
isSelected,
)}
aria-pressed={isSelected}
className={cn(
"w-full rounded-xl border p-4 text-left transition-[background-color,border-color,box-shadow] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
isPending && "cursor-not-allowed opacity-70",
)}
data-state={isSelected ? "selected" : "available"}
data-testid={`persona-catalog-detail-selection-target-${persona.id}`}
disabled={isPending}
onClick={() => {
onTogglePersona(persona);
}}
type="button"
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-sm font-semibold tracking-tight"
data-testid="persona-catalog-detail-selection-title"
>
{selectionCopy.title}
</p>
<p
className="mt-1 text-sm text-muted-foreground"
data-testid="persona-catalog-detail-selection-description"
>
{selectionCopy.description}
</p>
</div>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
</button>
<button
aria-label={getPersonaCatalogSelectionAriaLabel(
persona.displayName,
isSelected,
)}
aria-pressed={isSelected}
className={cn(
"w-full rounded-xl border p-4 text-left transition-[background-color,border-color,box-shadow] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2",
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border/80 bg-background/60 text-muted-foreground hover:bg-accent hover:text-accent-foreground",
isPending && "cursor-not-allowed opacity-70",
)}
data-state={isSelected ? "selected" : "available"}
data-testid={`persona-catalog-detail-selection-target-${persona.id}`}
disabled={isPending}
onClick={() => {
onTogglePersona(persona);
}}
type="button"
>
<div className="flex items-start justify-between gap-4">
<div>
<p
className="text-sm font-semibold tracking-tight"
data-testid="persona-catalog-detail-selection-title"
>
{selectionCopy.title}
{feedbackNoticeMessage ? (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{feedbackErrorMessage}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Type
</p>
<p
className="mt-1 text-sm text-muted-foreground"
data-testid="persona-catalog-detail-selection-description"
>
{selectionCopy.description}
<p className="mt-2 text-sm font-medium">Built-in persona</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred model
</p>
<p className="mt-2 text-sm font-medium">
{persona.model ?? "Use app default"}
</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred provider
</p>
<p className="mt-2 text-sm font-medium">
{persona.provider ?? "Use app default"}
</p>
</div>
<PersonaCatalogSelectionBadge isActive={isSelected} />
</div>
</button>
{feedbackNoticeMessage ? (
<p className="rounded-2xl border border-primary/20 bg-primary/10 px-4 py-3 text-sm text-primary">
{feedbackNoticeMessage}
</p>
) : null}
{feedbackErrorMessage ? (
<p className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{feedbackErrorMessage}
</p>
) : null}
<div className="grid gap-3 sm:grid-cols-2">
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Type
</p>
<p className="mt-2 text-sm font-medium">Built-in persona</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred model
</p>
<p className="mt-2 text-sm font-medium">
{persona.model ?? "Use app default"}
</p>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4 sm:col-span-2">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
Preferred provider
</p>
<p className="mt-2 text-sm font-medium">
{persona.provider ?? "Use app default"}
System prompt
</p>
<pre className="mt-3 whitespace-pre-wrap break-words font-sans text-sm leading-6 text-foreground">
{persona.systemPrompt}
</pre>
</div>
</div>
<div className="rounded-xl border border-border/70 bg-card/70 p-4">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">
System prompt
</p>
<pre className="mt-3 whitespace-pre-wrap break-words font-sans text-sm leading-6 text-foreground">
{persona.systemPrompt}
</pre>
</div>
</div>
</>
) : null}
</SheetContent>
</Sheet>

View File

@@ -38,7 +38,6 @@ import {
} from "@/shared/ui/alert-dialog";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
import { Separator } from "@/shared/ui/separator";
import {
Sheet,
SheetContent,
@@ -214,7 +213,7 @@ export function ChannelManagementSheet({
data-testid="channel-management-sheet"
side="right"
>
<SheetHeader className="space-y-4 border-b border-border/80 bg-muted/20 px-6 py-6 text-left">
<SheetHeader className="relative z-10 space-y-4 bg-background/25 px-6 py-6 text-left shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
<div className="space-y-2">
<SheetTitle className="pr-8">{channel.name}</SheetTitle>
<SheetDescription>
@@ -314,8 +313,6 @@ export function ChannelManagementSheet({
</p>
) : null}
</Section>
<Separator />
</>
) : null}
@@ -330,8 +327,6 @@ export function ChannelManagementSheet({
/>
</Section>
<Separator />
<Section
description="Topic and purpose show the current context for the channel."
title="Context"
@@ -415,8 +410,6 @@ export function ChannelManagementSheet({
</form>
</Section>
<Separator />
<Section
description="Name and description are owner/admin actions."
title="Details"
@@ -481,8 +474,6 @@ export function ChannelManagementSheet({
{resolvedChannel.channelType !== "dm" ? (
<>
<Separator />
<Section
description="Archiving keeps history but blocks new changes."
title="Channel state"
@@ -541,8 +532,6 @@ export function ChannelManagementSheet({
{isOwner && resolvedChannel.channelType !== "dm" ? (
<>
<Separator />
<Section
description="Deleting removes the channel from the workspace list."
title="Danger zone"

View File

@@ -183,7 +183,7 @@ export function MembersSidebar({
data-testid="members-sidebar"
side="right"
>
<SheetHeader className="space-y-2 border-b border-border/80 bg-muted/20 px-6 py-6 text-left">
<SheetHeader className="relative z-10 space-y-2 bg-background/25 px-6 py-6 text-left shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
<SheetTitle>Members</SheetTitle>
<SheetDescription>
People and bots in {channel.name}.

View File

@@ -4,15 +4,15 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="flex items-center gap-3 py-2"
className="flex items-center gap-2.5 py-1.5"
data-testid="message-timeline-day-divider"
data-day-label={label}
>
<Separator className="flex-1" />
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground">
<Separator className="flex-1 bg-border/35" />
<p className="text-[10px] font-medium tracking-[0.02em] text-muted-foreground/65">
{label}
</p>
<Separator className="flex-1" />
<Separator className="flex-1 bg-border/35" />
</section>
);
}

View File

@@ -1,3 +1,4 @@
import * as React from "react";
import { ArrowLeft, X } from "lucide-react";
import type { MainTimelineEntry } from "@/features/messages/lib/threadPanel";
@@ -75,12 +76,11 @@ export function MessageThreadPanel({
threadReplies,
threadTypingPubkeys,
}: MessageThreadPanelProps) {
if (!threadHead) {
return null;
}
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const threadContentRef = React.useRef<HTMLDivElement>(null);
const composerReplyTarget =
replyTargetMessage && replyTargetMessage.id !== threadHead.id
threadHead && replyTargetMessage && replyTargetMessage.id !== threadHead.id
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
@@ -88,9 +88,52 @@ export function MessageThreadPanel({
}
: null;
const scrollThreadToBottom = React.useCallback(() => {
const threadBody = threadBodyRef.current;
if (!threadBody) {
return;
}
threadBody.scrollTo({
top: threadBody.scrollHeight,
behavior: "auto",
});
}, []);
React.useLayoutEffect(() => {
if (!threadHead) {
return;
}
scrollThreadToBottom();
}, [scrollThreadToBottom, threadHead?.id]);
React.useEffect(() => {
if (!threadHead) {
return;
}
const threadContent = threadContentRef.current;
if (!threadContent || typeof ResizeObserver === "undefined") {
return;
}
const observer = new ResizeObserver(() => {
scrollThreadToBottom();
});
observer.observe(threadContent);
return () => {
observer.disconnect();
};
}, [scrollThreadToBottom, threadHead?.id]);
if (!threadHead) {
return null;
}
return (
<aside
className="relative z-10 hidden h-full min-h-0 w-[min(100%,420px)] shrink-0 flex-col border-l border-border/60 bg-muted/20 pt-0 lg:flex"
className="relative z-10 hidden h-full min-h-0 w-[min(100%,420px)] shrink-0 flex-col border-l border-border/60 bg-muted/20 pt-14 lg:flex"
data-testid="message-thread-panel"
>
<div className="relative z-20 flex shrink-0 items-center justify-between bg-background/25 px-2 py-1 shadow-[0_4px_24px_rgba(0,0,0,0.06)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.25)]">
@@ -124,53 +167,59 @@ export function MessageThreadPanel({
</div>
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-12"
data-testid="message-thread-body"
ref={threadBodyRef}
>
<div className="px-4 py-3" data-testid="message-thread-head">
<div className="mx-auto w-full max-w-[23rem]">
<MessageRow
activeReplyTargetId={replyTargetId}
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
</div>
<div className="px-4 pb-2 pt-3" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="mx-auto w-full max-w-[23rem] space-y-2">
{threadReplies.map((entry) => (
<div key={entry.message.id}>
<MessageRow
activeReplyTargetId={replyTargetId}
message={entry.message}
onDelete={
onDelete && canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onReply={onOpenNestedThread}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary ? (
<MessageThreadSummaryRow
message={entry.message}
onOpenThread={onOpenNestedThread}
summary={entry.summary}
/>
) : null}
</div>
))}
<div ref={threadContentRef}>
<div className="px-4 py-3" data-testid="message-thread-head">
<div className="mx-auto w-full max-w-[23rem]">
<MessageRow
activeReplyTargetId={replyTargetId}
message={threadHead}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
: undefined
}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
</div>
) : null}
</div>
<div className="px-4 pb-2 pt-3" data-testid="message-thread-replies">
{threadReplies.length > 0 ? (
<div className="mx-auto w-full max-w-[23rem] space-y-2">
{threadReplies.map((entry) => (
<div
key={entry.message.id}
className="flex flex-col gap-0"
>
<MessageRow
activeReplyTargetId={replyTargetId}
message={entry.message}
onDelete={
onDelete && canManageMessage(entry.message, currentPubkey)
? onDelete
: undefined
}
onReply={onOpenNestedThread}
onToggleReaction={onToggleReaction}
profiles={profiles}
/>
{entry.summary ? (
<MessageThreadSummaryRow
message={entry.message}
onOpenThread={onOpenNestedThread}
summary={entry.summary}
/>
) : null}
</div>
))}
</div>
) : null}
</div>
</div>
</div>

View File

@@ -35,7 +35,7 @@ export function MessageThreadSummaryRow({
}) {
return (
<button
className="-mt-1 ml-8 flex w-fit max-w-full items-center gap-2 rounded-xl px-3 py-1 text-left text-sm text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
className="-mt-0.5 ml-10 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"
data-thread-head-id={message.id}
data-testid="message-thread-summary"
onClick={() => onOpenThread(message)}

View File

@@ -95,23 +95,23 @@ export const MessageTimeline = React.memo(function MessageTimeline({
<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-0 z-10 flex justify-center px-4 pt-1 sm:px-6"
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 bg-muted/60 px-2 py-0.5 text-[11px] font-medium leading-none tracking-normal text-muted-foreground/75 shadow-sm backdrop-blur-sm">
<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-3 pt-1 [overflow-anchor:none] sm:px-6"
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"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-2 pb-36 pt-10"
className="mx-auto flex w-full max-w-4xl flex-col gap-2 pb-8 pt-14"
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />

View File

@@ -74,6 +74,34 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
time={message.time}
/>,
);
} else if (summary && onReply) {
elements.push(
<div key={message.id} className="flex flex-col gap-0">
<MessageRow
activeReplyTargetId={activeReplyTargetId}
highlighted={message.id === highlightedMessageId}
message={message}
onDelete={
onDelete && currentPubkey && message.pubkey === currentPubkey
? onDelete
: undefined
}
onEdit={
onEdit && currentPubkey && message.pubkey === currentPubkey
? onEdit
: undefined
}
onToggleReaction={onToggleReaction}
onReply={onReply}
profiles={profiles}
/>
<MessageThreadSummaryRow
message={message}
onOpenThread={onReply}
summary={summary}
/>
</div>,
);
} else {
elements.push(
<MessageRow
@@ -96,17 +124,6 @@ export const TimelineMessageList = React.memo(function TimelineMessageList({
profiles={profiles}
/>,
);
if (summary && onReply) {
elements.push(
<MessageThreadSummaryRow
key={`thread-summary-${message.id}`}
message={message}
onOpenThread={onReply}
summary={summary}
/>,
);
}
}
}

View File

@@ -1,5 +1,7 @@
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.
@@ -27,13 +29,14 @@ export function useStickyDayHeader(
}
const containerTop = container.getBoundingClientRect().top;
const stickyTriggerTop = containerTop + STICKY_DAY_TRIGGER_OFFSET_PX;
// Walk dividers from the end — the last one whose top is at or above the
// container's top edge is the "current" day.
// 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 <= containerTop + 4) {
if (rect.top <= stickyTriggerTop) {
current = dividers[i].getAttribute("data-day-label");
break;
}

View File

@@ -43,10 +43,10 @@
}
.rich-text-composer .tiptap pre {
border-radius: 1rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--border) / 0.7);
background: hsl(var(--muted) / 0.6);
padding: 0.75rem 1rem;
padding: 0.375rem 0.75rem;
overflow-x: auto;
margin: 0.25rem 0;
}

View File

@@ -227,7 +227,7 @@ function createMarkdownComponents(
return <p className={paragraphClassName}>{children}</p>;
},
pre: ({ children }) => (
<pre className="overflow-x-auto rounded-2xl border border-border/70 bg-muted/60 px-4 py-3 shadow-sm">
<pre className="overflow-x-auto rounded-xl border border-border/70 bg-muted/60 px-3 py-1.5 shadow-sm">
{children}
</pre>
),