desktop: polish chat timeline, header, and channel layout

- Subtler day dividers and compact date headings; remove chat header border
- Reposition scroll-to-latest above composer with muted outline style
- Channel pane/screen layout and related sheet/sidebar tweaks

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-07 10:05:44 +02:00
parent dde459dc5d
commit c8c124506b
14 changed files with 164 additions and 157 deletions
@@ -210,7 +210,7 @@ export function ChannelManagementSheet({
return (
<Sheet onOpenChange={handleSheetOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-xl"
className="flex w-full flex-col gap-0 overflow-hidden bg-background p-0 sm:max-w-xl"
data-testid="channel-management-sheet"
side="right"
>
@@ -71,7 +71,7 @@ export const ChannelPane = React.memo(function ChannelPane({
typingPubkeys,
}: ChannelPaneProps) {
return (
<>
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={replyTargetId}
@@ -101,49 +101,51 @@ export const ChannelPane = React.memo(function ChannelPane({
onToggleReaction={onToggleReaction}
targetMessageId={targetMessageId}
/>
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending
}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
replyTarget={
replyTargetMessage
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null
}
/>
</>
<div className="relative z-10 -mt-10 shrink-0">
<TypingIndicatorRow
channel={activeChannel}
currentPubkey={currentPubkey}
profiles={profiles}
typingPubkeys={typingPubkeys}
/>
<MessageComposer
channelId={activeChannel?.id ?? null}
channelName={activeChannel?.name ?? "channel"}
disabled={
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
activeChannel.channelType === "forum" ||
isSending
}
editTarget={editTarget}
isSending={isSending}
onCancelEdit={onCancelEdit}
onCancelReply={onCancelReply}
onEditSave={onEditSave}
onSend={onSend}
placeholder={
activeChannel?.archivedAt
? "Archived channels are read-only."
: activeChannel && !activeChannel.isMember
? "Join this channel to message."
: activeChannel?.channelType === "forum"
? "Forum posting is not wired in this pass."
: activeChannel
? `Message #${activeChannel.name}`
: "Select a channel"
}
replyTarget={
replyTargetMessage
? {
author: replyTargetMessage.author,
body: replyTargetMessage.body,
id: replyTargetMessage.id,
}
: null
}
/>
</div>
</div>
);
});
@@ -343,92 +343,95 @@ export function ChannelScreen({
return (
<>
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
/>
) : null
}
channelType={activeChannel?.channelType}
visibility={activeChannel?.visibility}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannelTitle}
/>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading forum..." />}
>
<ForumView
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<ChatHeader
actions={
activeChannel ? (
<ChannelMembersBar
channel={activeChannel}
currentPubkey={currentPubkey}
onManageChannel={onManageChannel}
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
/>
</React.Suspense>
) : null
}
channelType={activeChannel?.channelType}
visibility={activeChannel?.visibility}
description={channelDescription}
statusBadge={
activeChannel?.channelType === "dm" && activeDmPresenceStatus ? (
<PresenceBadge
data-testid="chat-presence-badge"
status={activeDmPresenceStatus}
/>
) : null
}
title={activeChannelTitle}
/>
<div className="relative z-0 -mt-8 flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
{activeChannel ? (
activeChannel.channelType === "forum" ? (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading forum..." />}
>
<ForumView
channel={activeChannel}
currentPubkey={currentPubkey}
/>
</React.Suspense>
) : (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading channel..." />}
>
<ChannelPane
activeChannel={activeChannel}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: null
}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelReply={handleCancelReply}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onReply={handleReply}
onSend={handleSend}
onTargetReached={onTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel &&
searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
typingPubkeys={typingPubkeys}
/>
</React.Suspense>
)
) : (
<React.Suspense
fallback={<ViewLoadingFallback label="Loading channel..." />}
>
<ChannelPane
activeChannel={activeChannel}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
isFetchingOlder={isFetchingOlder}
editTarget={
editTargetMessage
? {
author: editTargetMessage.author,
body: editTargetMessage.body,
id: editTargetMessage.id,
}
: null
}
isSending={sendMessageMutation.isPending}
isTimelineLoading={isTimelineLoading}
messages={timelineMessages}
onCancelEdit={handleCancelEdit}
onCancelReply={handleCancelReply}
onDelete={handleDelete}
onEdit={handleEdit}
onEditSave={handleEditSave}
onReply={handleReply}
onSend={handleSend}
onTargetReached={onTargetReached}
onToggleReaction={effectiveToggleReaction}
profiles={messageProfiles}
replyTargetId={replyTargetId}
replyTargetMessage={replyTargetMessage}
targetMessageId={
activeChannel && searchAnchor?.channelId === activeChannel.id
? searchAnchor.eventId
: null
}
typingPubkeys={typingPubkeys}
/>
</React.Suspense>
)
) : (
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
<p className="text-sm text-muted-foreground">
Select a channel to view messages.
</p>
</div>
)}
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-8">
<p className="text-sm text-muted-foreground">
Select a channel to view messages.
</p>
</div>
)}
</div>
</div>
<MembersSidebar
@@ -147,7 +147,7 @@ export function MembersSidebar({
return (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetContent
className="flex w-full flex-col gap-0 overflow-hidden border-l border-border/80 bg-background p-0 sm:max-w-md"
className="flex w-full flex-col gap-0 overflow-hidden bg-background p-0 sm:max-w-md"
data-testid="members-sidebar"
side="right"
>
+1 -1
View File
@@ -60,7 +60,7 @@ export function ChatHeader({
}: ChatHeaderProps) {
return (
<header
className="flex min-w-0 items-center gap-3 border-b border-border/80 bg-background px-4 pb-3 pt-8 sm:px-6"
className="relative z-20 flex shrink-0 min-w-0 items-center gap-3 bg-background/25 px-4 pb-2 pt-6 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)] sm:px-6"
data-testid="chat-header"
data-tauri-drag-region
>
@@ -5,7 +5,7 @@
* - `formatFullDateTime` — verbose string for tooltips
* ("Wednesday, April 2, 2026 at 2:34 PM").
* - `formatDayHeading` — label for day dividers / sticky headers.
* Returns "Today", "Yesterday", or a date like "Monday, March 31, 2026".
* Returns "Today", "Yesterday", or a compact date like "Sat, Mar 21, 2026".
* - `isSameDay` — compare two unix-second timestamps.
*/
@@ -23,11 +23,12 @@ const FULL_DATE_TIME_FORMATTER = new Intl.DateTimeFormat("en-US", {
minute: "2-digit",
});
const DAY_HEADING_FORMATTER = new Intl.DateTimeFormat("en-US", {
weekday: "long",
year: "numeric",
month: "long",
/** Compact calendar line for dividers (not Today/Yesterday). */
const DAY_HEADING_COMPACT = new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
/** Short clock time, e.g. "2:34 PM". */
@@ -42,7 +43,7 @@ export function formatFullDateTime(unixSeconds: number): string {
/**
* Human-friendly day label for dividers and sticky headers.
* Returns "Today", "Yesterday", or a full date like "Monday, March 31, 2026".
* Returns "Today", "Yesterday", or a short date like "Sat, Mar 21, 2026".
*/
export function formatDayHeading(unixSeconds: number): string {
const date = new Date(unixSeconds * 1_000);
@@ -58,7 +59,7 @@ export function formatDayHeading(unixSeconds: number): string {
return "Yesterday";
}
return DAY_HEADING_FORMATTER.format(date);
return DAY_HEADING_COMPACT.format(date);
}
/** True when two unix-second timestamps fall on the same calendar day (local time). */
@@ -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 py-1"
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/50" />
<p className="shrink-0 text-[11px] font-medium leading-none tracking-normal text-muted-foreground/70">
{label}
</p>
<Separator className="flex-1" />
<Separator className="flex-1 bg-border/50" />
</section>
);
}
@@ -493,10 +493,10 @@ export function MessageComposer({
}, [media.handlePaperclip]);
return (
<footer className="border-t border-border/80 bg-background p-4">
<footer className="shrink-0 bg-transparent px-4 pb-4 pt-0">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
<form
className="relative rounded-2xl border border-input bg-card px-3 py-4 shadow-sm sm:px-4"
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"
data-testid="message-composer"
onDragOver={media.handleDragOver}
onDrop={(e) => {
@@ -89,25 +89,25 @@ export const MessageTimeline = React.memo(function MessageTimeline({
return (
<TooltipProvider delayDuration={200}>
<div className="relative min-h-0 flex-1">
<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-2 sm:px-6"
className="pointer-events-none absolute inset-x-0 top-0 z-10 flex justify-center px-4 pt-1 sm:px-6"
data-testid="message-timeline-sticky-day"
>
<p className="rounded-full bg-muted/90 px-3 py-1 text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground shadow-sm backdrop-blur-sm">
<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">
{stickyDayLabel}
</p>
</div>
) : null}
<div
className="h-full overflow-y-auto overflow-x-hidden overscroll-contain px-4 py-3 [overflow-anchor:none] sm:px-6"
className="absolute inset-0 overflow-y-auto overflow-x-hidden overscroll-contain px-4 pb-3 pt-1 [overflow-anchor:none] sm:px-6"
data-testid="message-timeline"
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className="mx-auto flex w-full max-w-4xl flex-col gap-2"
className="mx-auto flex w-full max-w-4xl flex-col gap-2 pb-36 pt-10"
ref={contentRef}
>
<div ref={topSentinelRef} aria-hidden className="h-px" />
@@ -166,17 +166,18 @@ export const MessageTimeline = React.memo(function MessageTimeline({
</div>
{!isAtBottom ? (
<div className="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-4">
<div className="pointer-events-none absolute inset-x-0 bottom-12 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="message-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"}
@@ -72,7 +72,7 @@ export function TypingIndicatorRow({
return (
<div
aria-live="polite"
className="bg-background/95 px-4 py-2 sm:px-6"
className="shrink-0 bg-transparent px-4 py-2 sm:px-6"
data-testid="message-typing-indicator"
>
<div className="mx-auto flex w-full max-w-4xl items-center">
@@ -144,7 +144,7 @@ export function SettingsView({
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] overflow-hidden lg:grid-cols-[220px_minmax(0,1fr)] lg:grid-rows-1">
<aside
className={cn(
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0 lg:border-r",
"border-b border-border/70 bg-muted/20 motion-safe:transition-all motion-safe:duration-200 motion-safe:ease-out lg:border-b-0",
isLoaded
? "opacity-100 translate-x-0"
: "opacity-0 -translate-x-2",
@@ -54,7 +54,7 @@ export function WorkflowDetailPanel({
return (
<div
className="flex h-full flex-col border-l bg-background"
className="flex h-full flex-col bg-background"
data-testid="workflow-detail-panel"
>
<div className="flex items-center justify-between border-b px-4 py-3">
+2 -2
View File
@@ -38,9 +38,9 @@ const sheetVariants = cva(
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
bottom:
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
left: "inset-y-0 left-0 h-full w-3/4 data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
right:
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
"inset-y-0 right-0 h-full w-3/4 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
},
},
defaultVariants: {
+1 -1
View File
@@ -249,7 +249,7 @@ const Sidebar = React.forwardRef<
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)_+_theme(spacing.4)_+2px)]"
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon] group-data-[side=left]:border-r group-data-[side=right]:border-l",
: "group-data-[collapsible=icon]:w-[--sidebar-width-icon]",
className,
)}
{...props}