Polish conversation view: composer overlay, blur, and spacing (#507)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
thomaspblock
2026-05-08 11:50:47 -04:00
committed by GitHub
parent ff0806523a
commit 5e7f588d82
10 changed files with 92 additions and 85 deletions

View File

@@ -328,16 +328,6 @@ export const ChannelPane = React.memo(function ChannelPane({
searchQuery={channelFind.query}
targetMessageId={targetMessageId}
/>
{hasTypingActivity ? (
<div className="relative bg-background">
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
</div>
) : null}
{isNonMemberView ? (
<div
data-testid="join-banner"
@@ -365,37 +355,47 @@ export const ChannelPane = React.memo(function ChannelPane({
</Button>
</div>
) : (
<div className="relative z-10 shrink-0">
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
toolbarExtraActions={
<BotActivityComposerAction
agents={activityAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
{hasTypingActivity ? (
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
typingPubkeys={typingPubkeys}
/>
}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
) : null}
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={isComposerDisabled}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onEditSave={onEditSave}
onSend={onSendMessage}
profiles={profiles}
toolbarExtraActions={
<BotActivityComposerAction
agents={activityAgents}
onOpenAgentSession={onOpenAgentSession}
openAgentSessionPubkey={openAgentSessionPubkey}
profiles={profiles}
typingBotPubkeys={composerBotTypingPubkeys}
/>
}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
showTopBorder={false}
/>
</div>
</div>
)}
</div>

View File

@@ -2,7 +2,7 @@ export function DayDivider({ label }: { label: string }) {
return (
<section
aria-label={label}
className="sticky top-0 z-30 flex justify-center py-1"
className="sticky top-0 z-[1] flex justify-center py-1"
data-testid="message-timeline-day-divider"
data-day-label={label}
>

View File

@@ -544,13 +544,17 @@ export function MessageComposer({
return (
<footer
className={cn(
"shrink-0 bg-transparent px-4 pb-4 pt-0",
"relative shrink-0 bg-transparent px-4 pb-4 pt-0",
showTopBorder ? "border-t border-border/40 pt-3" : "",
)}
>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
<div
aria-hidden="true"
className="absolute inset-x-0 bottom-0 h-5 bg-background"
/>
<div className="relative mx-auto flex w-full max-w-4xl flex-col gap-3">
<form
className="relative isolate rounded-2xl border border-border/50 bg-background/25 px-3 py-4 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/20 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] sm:px-4"
className="relative isolate rounded-2xl border border-border/50 bg-background/70 px-3 py-3 shadow-[0_4px_24px_rgba(0,0,0,0.08)] backdrop-blur-xl supports-[backdrop-filter]:bg-background/55 dark:shadow-[0_4px_24px_rgba(0,0,0,0.35)] sm:px-4"
data-testid="message-composer"
onDragOver={media.handleDragOver}
onDrop={(e) => {

View File

@@ -52,8 +52,8 @@ export const MessageComposerToolbar = React.memo(
sendDisabled: boolean;
}) {
return (
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 items-center gap-1 py-1 min-h-11">
<div className="mt-2 flex flex-wrap items-center justify-between gap-3">
<div className="flex min-h-10 min-w-0 flex-1 items-center gap-1 py-1">
{/*
* AnimatePresence with mode="popLayout" — exiting elements
* are popped out of flow immediately so entering elements

View File

@@ -127,7 +127,7 @@ export const MessageRow = React.memo(
const guideBleedPx = isThreadReplyLayout ? 4 : 0;
const avatarSizeClass = isThreadReplyLayout
? "!h-5 !w-5 !rounded-md"
: "!h-8 !w-8";
: "!h-9 !w-9";
const avatarButtonRadiusClass = isThreadReplyLayout
? "rounded-md"
: "rounded-xl";
@@ -321,8 +321,8 @@ export const MessageRow = React.memo(
) : (
<div className="flex shrink-0 items-start">{avatarNode}</div>
)}
<div className="-mt-1 min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0.5">
<div className="-mt-1 min-w-0 flex-1 space-y-0">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-0">
{message.pubkey ? (
<UserProfilePopover
pubkey={message.pubkey}
@@ -347,7 +347,7 @@ export const MessageRow = React.memo(
) : null}
{metadataNode}
</div>
{messageBodyNode}
<div className="-mt-0.5">{messageBodyNode}</div>
</div>
</>
)}

View File

@@ -187,12 +187,12 @@ export function MessageThreadPanel({
</div>
<div
className="min-h-0 flex-1 overflow-y-auto pb-6"
className="min-h-0 flex-1 overflow-y-auto pb-24"
data-testid="message-thread-body"
onScroll={syncScrollState}
ref={threadBodyRef}
>
<div ref={contentRef}>
<div className="pb-8" ref={contentRef}>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
@@ -273,15 +273,16 @@ export function MessageThreadPanel({
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-16 flex justify-center px-4">
<div className="pointer-events-none absolute inset-x-0 bottom-32 z-20 flex justify-center px-4">
<Button
className="pointer-events-auto rounded-full shadow-lg"
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-sm backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
data-testid="thread-scroll-to-latest"
onClick={() => scrollToBottom("smooth")}
size="sm"
type="button"
variant="outline"
>
<ArrowDown className="h-4 w-4" />
<ArrowDown aria-hidden />
{newMessageCount > 0
? `${newMessageCount} new message${newMessageCount === 1 ? "" : "s"}`
: "Jump to latest"}
@@ -289,30 +290,32 @@ export function MessageThreadPanel({
</div>
) : null}
<div>
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
<MessageComposer
channelId={channelId}
channelName={channelName}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
replyTarget={composerReplyTarget}
toolbarExtraActions={toolbarExtraActions}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div className="pointer-events-auto">
<TypingIndicatorRow
channel={channel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={threadTypingPubkeys}
/>
<MessageComposer
channelId={channelId}
channelName={channelName}
disabled={disabled || isSending || !channelId}
draftKey={`thread:${threadHead.id}`}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={composerReplyTarget ? onCancelReply : undefined}
onEditSave={onEditSave}
onSend={onSend}
placeholder={`Reply in thread to ${threadHead.author}`}
replyTarget={composerReplyTarget}
toolbarExtraActions={toolbarExtraActions}
typingParentEventId={threadHead.id}
typingRootEventId={threadHead.rootId}
/>
</div>
</div>
</aside>
</>

View File

@@ -126,7 +126,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
<TooltipProvider delayDuration={200}>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<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"
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-24 pt-1 [overflow-anchor:none] sm:px-6"
data-scroll-restoration-id="message-timeline"
data-testid="message-timeline"
onScroll={syncScrollState}
@@ -197,7 +197,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-12 z-20 flex justify-center px-4">
<div className="pointer-events-none absolute inset-x-0 bottom-32 z-20 flex justify-center px-4">
<Button
className="pointer-events-auto h-7 min-h-7 gap-1.5 rounded-full border-border/50 bg-background/85 px-2.5 text-[11px] font-medium text-muted-foreground shadow-sm backdrop-blur-sm hover:bg-muted/70 hover:text-foreground [&_svg]:size-3.5"
data-testid="message-scroll-to-latest"

View File

@@ -206,7 +206,7 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
<div className="flex items-start gap-2.5">
<UserAvatar
avatarUrl={resolveAvatarUrl(avatarPubkey, profiles)}
className="!h-8 !w-8 shrink-0 text-[10px]"
className="!h-9 !w-9 shrink-0 text-[10px]"
displayName={avatarLabel}
testId="system-message-avatar"
/>

View File

@@ -328,7 +328,7 @@ test("opens a single-level thread panel with inline expansion", async ({
return body.scrollHeight - body.clientHeight;
});
})
.toBeGreaterThan(160);
.toBeGreaterThanOrEqual(160);
await expect(
timeline.getByTestId("message-row").filter({ hasText: firstReply }),
@@ -393,7 +393,7 @@ test("opens a single-level thread panel with inline expansion", async ({
return rowRect.top - bodyRect.top;
});
})
.toBeLessThan(160);
.toBeLessThanOrEqual(160);
const firstReplyId = await firstReplyRow.getAttribute("data-message-id");
if (!firstReplyId) {
@@ -447,7 +447,7 @@ test("opens a single-level thread panel with inline expansion", async ({
return rowRect.top - bodyRect.top;
});
})
.toBeLessThan(160);
.toBeLessThanOrEqual(160);
await firstReplySummaryRow.click();
await expect(

View File

@@ -424,7 +424,7 @@ test("does not shift the timeline when the composer grows", async ({
await page.waitForTimeout(1200);
const after = await getTimelineMetrics(page);
expect(after.clientHeight).toBeLessThan(before.clientHeight);
expect(after.clientHeight).toBeLessThanOrEqual(before.clientHeight);
expect(Math.abs(after.scrollTop - before.scrollTop)).toBeLessThanOrEqual(2);
expect(after.distanceFromBottom).toBeGreaterThan(160);
});