desktop: message layout, circular send, cleaner sidebar

Tighten message row typography and spacing; keep actions in an absolute
header row so the action bar cannot wrap and gap the body; add light
padding before message content.

Adjust tight Markdown block margins so sibling blocks stack without extra
first-block gap.

Replace the composer send control with a circular primary button and an
up-arrow icon; update smoke e2e to assert title instead of label text.

Remove sidebar rail and submenu vertical lines, drop header/content/footer
SidebarSeparators, and trim SidebarMenuSub border alignment offsets.

Made-with: Cursor
This commit is contained in:
Thomas Petersen
2026-04-07 11:39:58 +02:00
parent c8c124506b
commit dcd722b8c3
6 changed files with 76 additions and 66 deletions
@@ -1,5 +1,5 @@
import * as React from "react";
import { AtSign, Paperclip, SendHorizontal } from "lucide-react";
import { ArrowUp, AtSign, Paperclip } from "lucide-react";
import { Button } from "@/shared/ui/button";
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
@@ -67,14 +67,22 @@ export const MessageComposerToolbar = React.memo(
</div>
<Button
className="gap-2"
aria-label={isSending ? "Sending" : "Send message"}
className="rounded-full"
data-testid="send-message"
disabled={sendDisabled || isSending}
title="Send (Enter)"
title={isSending ? "Sending…" : "Send (Enter)"}
type="submit"
size="icon"
>
<SendHorizontal className="h-4 w-4" />
{isSending ? "Sending" : "Send"}
{isSending ? (
<span
aria-hidden
className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
/>
) : (
<ArrowUp className="h-4 w-4" aria-hidden />
)}
</Button>
</div>
);
+54 -50
View File
@@ -228,61 +228,65 @@ export const MessageRow = React.memo(
</div>
)}
<div className="min-w-0 flex-1 space-y-0.5">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="truncate rounded text-sm font-semibold tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
<div className="relative min-w-0 flex-1 space-y-0">
{/* Name + role only in flow — action bar + time are absolute so flex-wrap
cannot insert a full-width row between the username and the body. */}
<div className="flex min-w-0 flex-nowrap items-start gap-x-2 pr-[9.5rem] sm:pr-40">
<div className="flex min-w-0 flex-1 items-center gap-x-2">
{message.pubkey ? (
<UserProfilePopover pubkey={message.pubkey}>
<button
className="min-w-0 truncate rounded pt-px text-left text-sm font-semibold leading-tight tracking-tight hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
type="button"
>
{message.author}
</button>
</UserProfilePopover>
) : (
<h3 className="min-w-0 truncate pt-px text-sm font-semibold leading-tight tracking-tight">
{message.author}
</button>
</UserProfilePopover>
) : (
<h3 className="truncate text-sm font-semibold tracking-tight">
{message.author}
</h3>
)}
{message.role ? (
<p className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
) : null}
<div className="ml-auto flex items-center gap-2 text-xs text-muted-foreground">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={onReply}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
/>
{message.pending ? (
<p className="font-medium uppercase tracking-[0.14em] text-primary/80">
Sending
</h3>
)}
{message.role ? (
<p className="shrink-0 rounded-full bg-muted px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-muted-foreground">
{message.role}
</p>
) : null}
{message.edited ? (
<p
className="text-muted-foreground/70"
title="This message has been edited"
>
(edited)
</p>
) : null}
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
/>
</div>
</div>
{renderBody()}
<div className="absolute right-0 top-0 z-10 flex items-start justify-end gap-2 pt-px text-xs text-muted-foreground">
<MessageActionBar
activeReplyTargetId={activeReplyTargetId}
message={message}
onDelete={onDelete}
onEdit={onEdit}
onReactionSelect={
canToggleReactions ? handleReactionSelect : undefined
}
onReply={onReply}
reactionErrorMessage={reactionErrorMessage}
reactionPending={reactionPending}
reactions={reactions}
/>
{message.pending ? (
<p className="shrink-0 font-medium uppercase tracking-[0.14em] text-primary/80">
Sending
</p>
) : null}
{message.edited ? (
<p
className="shrink-0 text-muted-foreground/70"
title="This message has been edited"
>
(edited)
</p>
) : null}
<MessageTimestamp
createdAt={message.createdAt}
time={message.time}
/>
</div>
<div className="pt-1">{renderBody()}</div>
<MessageReactions
messageId={message.id}
reactions={reactions}
@@ -35,7 +35,6 @@ import {
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarSeparator,
} from "@/shared/ui/sidebar";
// ---------------------------------------------------------------------------
@@ -610,8 +609,6 @@ export function AppSidebar({
</SidebarMenu>
</SidebarHeader>
<SidebarSeparator className="mx-0 w-full" />
<SidebarContent>
{isLoading ? (
<SidebarGroup>
@@ -710,8 +707,6 @@ export function AppSidebar({
) : null}
</SidebarContent>
<SidebarSeparator className="mx-0 w-full" />
<SidebarFooter>
<SidebarMenu>
<SidebarMenuItem>
+1 -1
View File
@@ -237,7 +237,7 @@ function MarkdownInner({
<div
className={cn(
tight
? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1"
? "max-w-none break-words text-sm leading-5 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-0 [&>*+*]:mt-1"
: compact
? "max-w-none break-words text-[15px] leading-6 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-1.5"
: "max-w-none break-words text-sm leading-7 text-foreground/90 [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&>*]:my-3",
+4 -4
View File
@@ -308,10 +308,10 @@ const SidebarRail = React.forwardRef<
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] hover:after:bg-sidebar-border group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 sm:flex",
"[[data-side=left]_&]:cursor-w-resize [[data-side=right]_&]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:hover:bg-sidebar",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className,
@@ -698,7 +698,7 @@ const SidebarMenuSub = React.forwardRef<
ref={ref}
data-sidebar="menu-sub"
className={cn(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"mx-3.5 flex min-w-0 flex-col gap-1 px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className,
)}
@@ -730,7 +730,7 @@ const SidebarMenuSubButton = React.forwardRef<
data-size={size}
data-active={isActive}
className={cn(
"flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary-foreground",
"flex h-7 min-w-0 items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-none ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground data-[active=true]:[&>svg]:text-sidebar-primary-foreground",
"data-[active=true]:bg-sidebar-primary data-[active=true]:font-semibold data-[active=true]:text-sidebar-primary-foreground data-[active=true]:hover:bg-sidebar-primary data-[active=true]:hover:text-sidebar-primary-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
+4 -1
View File
@@ -262,7 +262,10 @@ test("supports multiline drafts with Ctrl+Enter and sends with Enter", async ({
await page.goto("/");
await page.getByTestId("channel-general").click();
await expect(page.getByTestId("chat-title")).toHaveText("general");
await expect(page.getByTestId("send-message")).toContainText("Send");
await expect(page.getByTestId("send-message")).toHaveAttribute(
"title",
"Send (Enter)",
);
const initialInputHeight = await input.evaluate(
(element) => (element as HTMLTextAreaElement).clientHeight,
);