Polish thread reply hover states (#1329)

This commit is contained in:
klopez4212
2026-06-27 06:27:51 -07:00
committed by GitHub
parent f00c86e744
commit aca40b6289
11 changed files with 100 additions and 67 deletions
+13 -7
View File
@@ -169,16 +169,22 @@ export function InboxListPane({
>
<button
className={cn(
"relative block w-full border-l px-3 py-4 text-left transition-colors after:pointer-events-none after:absolute after:bottom-0 after:left-[3.625rem] after:right-0 after:h-px after:bg-border/45 after:content-['']",
isSelected
? "border-l-transparent bg-[var(--inbox-row-highlight-bg)]"
: "border-l-transparent group-hover/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-focus-within/inbox-item:bg-[var(--inbox-row-highlight-bg)] active:bg-muted/40",
"relative block w-full border-l border-l-transparent px-3 py-4 text-left after:pointer-events-none after:absolute after:bottom-0 after:left-[3.625rem] after:right-3 after:h-px after:bg-border/45 after:content-['']",
index === items.length - 1 && "after:hidden",
)}
onClick={() => onSelect(item.id)}
type="button"
>
<div className="flex min-w-0 items-start gap-2.5">
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute inset-y-0 left-0 right-3 transition-colors",
isSelected
? "bg-[var(--inbox-row-highlight-bg)]"
: "group-hover/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-focus-within/inbox-item:bg-[var(--inbox-row-highlight-bg)] group-active/inbox-item:bg-muted/40",
)}
/>
<div className="relative flex min-w-0 items-start gap-2.5">
<div className="relative shrink-0">
<UserAvatar
avatarUrl={item.avatarUrl}
@@ -233,7 +239,7 @@ export function InboxListPane({
</div>
</button>
<div className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-0.5 rounded-full bg-[var(--inbox-row-highlight-bg)] p-1 opacity-0 transition-opacity duration-150 ease-out group-hover/inbox-item:pointer-events-auto group-hover/inbox-item:opacity-100 group-focus-within/inbox-item:pointer-events-auto group-focus-within/inbox-item:opacity-100">
<div className="pointer-events-none absolute right-3 top-2 z-10 flex items-center gap-0.5 rounded-full bg-[var(--inbox-row-highlight-bg)] p-1 opacity-0 transition-opacity duration-150 ease-out group-hover/inbox-item:pointer-events-auto group-hover/inbox-item:opacity-100 group-focus-within/inbox-item:pointer-events-auto group-focus-within/inbox-item:opacity-100">
{isDone ? (
<InboxRowActionButton
label="Mark unread"
@@ -444,7 +450,7 @@ export function InboxListPane({
</div>
) : (
<div
className="min-h-0 flex-1 overflow-y-auto overscroll-contain"
className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain"
data-testid="home-inbox-list"
ref={scrollRef}
>
@@ -12,27 +12,29 @@ import {
test("getThreadReplyIndentRem uses a visible Tailwind spacing step", () => {
assert.equal(getThreadReplyIndentRem(0), 0);
assert.equal(getThreadReplyIndentRem(1), 2.25);
assert.equal(getThreadReplyIndentRem(2), 4.5);
assert.equal(getThreadReplyIndentRem(3), 6.75);
assert.equal(getThreadReplyIndentRem(1), 0);
assert.equal(getThreadReplyIndentRem(2), 2.25);
assert.equal(getThreadReplyIndentRem(3), 4.5);
});
test("avatar center helpers expose the rail anchor points", () => {
assert.equal(getThreadReplyAvatarCenterRem(0), 1.875);
assert.equal(getThreadReplyAvatarCenterRem(1), 4.125);
assert.equal(getThreadReplyAvatarCenterRem(1), 1.875);
assert.equal(getThreadReplyAvatarCenterRem(2), 4.125);
assert.equal(getThreadReplyAvatarCenterYRem(), 1.5);
assert.equal(getThreadReplyDescendantRailStartYRem(), 2.875);
});
test("getThreadReplyConnectorLayout stops before the child avatar edge", () => {
assert.equal(getThreadReplyConnectorLayout(0), null);
assert.deepEqual(getThreadReplyConnectorLayout(1), {
assert.equal(getThreadReplyConnectorLayout(1), null);
assert.deepEqual(getThreadReplyConnectorLayout(2), {
childOffsetRem: 4.125,
heightRem: 1.5,
parentOffsetRem: 1.875,
widthRem: 0.875,
});
assert.deepEqual(getThreadReplyConnectorLayout(2), {
assert.deepEqual(getThreadReplyConnectorLayout(3), {
childOffsetRem: 6.375,
heightRem: 1.5,
parentOffsetRem: 4.125,
@@ -34,18 +34,34 @@ function clampVisibleDepth(depth: number) {
return Math.min(Math.max(depth, 0), THREAD_REPLY_MAX_VISIBLE_DEPTH);
}
export function getThreadReplyIndentRem(depth: number) {
const visibleDepth = clampVisibleDepth(depth);
function getThreadReplyVisualDepth(depth: number) {
return clampVisibleDepth(Math.max(0, depth - 1));
}
function getThreadReplyIndentForVisibleDepthRem(visibleDepth: number) {
return visibleDepth > 0
? THREAD_REPLY_ROOT_INDENT_REM +
(visibleDepth - 1) * THREAD_REPLY_NESTED_INDENT_REM
: 0;
}
export function getThreadReplyIndentRem(depth: number) {
return getThreadReplyIndentForVisibleDepthRem(
getThreadReplyVisualDepth(depth),
);
}
export function getThreadReplyAvatarCenterRem(depth: number) {
return getThreadReplyIndentRem(depth) + THREAD_REPLY_AVATAR_CENTER_OFFSET_REM;
}
function getThreadReplyAvatarCenterForVisibleDepthRem(visibleDepth: number) {
return (
getThreadReplyIndentForVisibleDepthRem(visibleDepth) +
THREAD_REPLY_AVATAR_CENTER_OFFSET_REM
);
}
export function getThreadReplyAvatarCenterYRem() {
return THREAD_REPLY_AVATAR_CENTER_Y_REM;
}
@@ -59,13 +75,16 @@ export function getThreadReplyDescendantRailStartYRem() {
}
export function getThreadReplyConnectorLayout(depth: number) {
const visibleDepth = clampVisibleDepth(depth);
const visibleDepth = getThreadReplyVisualDepth(depth);
if (visibleDepth === 0) {
return null;
}
const parentOffsetRem = getThreadReplyAvatarCenterRem(visibleDepth - 1);
const childOffsetRem = getThreadReplyAvatarCenterRem(visibleDepth);
const parentOffsetRem = getThreadReplyAvatarCenterForVisibleDepthRem(
visibleDepth - 1,
);
const childOffsetRem =
getThreadReplyAvatarCenterForVisibleDepthRem(visibleDepth);
const childEdgeOffsetRem =
childOffsetRem -
THREAD_REPLY_AVATAR_RADIUS_REM -
@@ -197,7 +197,10 @@ export const MessageRow = React.memo(
const depthGuideItems = React.useMemo(() => {
const depths =
depthGuideDepths ??
Array.from({ length: message.depth }, (_, depth) => depth);
Array.from(
{ length: Math.max(0, message.depth - 1) },
(_, index) => index + 1,
);
return depths.map((depth) => ({
depth,
@@ -149,6 +149,10 @@ function getActiveContinuationDepths({
const depths: number[] = [];
for (const ancestor of ancestors) {
if (ancestor.message.depth === 0) {
continue;
}
const childDepth = ancestor.message.depth + 1;
const pathChild =
message.depth === childDepth
@@ -626,27 +630,10 @@ export function MessageThreadPanel({
actionBarPlacement="inside"
agentPubkeys={agentPubkeys}
channelId={channelId}
collapseDescendantsLabel="Collapse thread"
connectDescendants={
shouldShowThreadBranchGuides &&
!isThreadHeadRepliesCollapsed &&
deferredThreadReplies.length > 0
}
highlightDescendantRail={
shouldShowThreadBranchGuides &&
!isThreadHeadRepliesCollapsed &&
highlightedBranch?.id === threadHead.id
}
isFollowingThread={isFollowingThread}
isUnread={isMessageUnreadById?.(threadHead.id)}
layoutVariant="thread-reply"
message={threadHead}
onCollapseDescendants={
isThreadHeadRepliesCollapsed
? undefined
: collapseThreadHeadReplies
}
onCollapseDescendantsHoverChange={handleCollapseBranchHoverChange}
onDelete={
onDelete && canManageMessage(threadHead, currentPubkey)
? onDelete
@@ -730,7 +717,7 @@ export function MessageThreadPanel({
return (
<div
className={cn(
"content-visibility-auto flex flex-col gap-0",
"content-visibility-auto-interactive flex flex-col gap-0",
entry.summary &&
"group/message rounded-2xl px-0 py-0.5 transition-colors hover:bg-muted/50 focus-within:bg-muted/50",
)}
@@ -88,7 +88,7 @@ export function MessageThreadSummaryRow({
: `View thread with ${summary.replyCount} ${replyLabel}`;
const guideDepths = depthGuideDepths
? [...depthGuideDepths]
: Array.from({ length: depth }, (_, index) => index);
: Array.from({ length: Math.max(0, depth - 1) }, (_, index) => index + 1);
const depthGuideItems = guideDepths.map((guideDepth) => ({
depth: guideDepth,
offset: getThreadReplyAvatarCenterRem(guideDepth),
@@ -1,5 +1,12 @@
import { formatFullDateTime } from "@/features/messages/lib/dateFormatters";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/shared/ui/tooltip";
const TIMESTAMP_TOOLTIP_DELAY_MS = 500;
export function MessageTimestamp({
createdAt,
@@ -9,15 +16,20 @@ export function MessageTimestamp({
time: string;
}) {
return (
<Tooltip>
<TooltipTrigger asChild>
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55">
{time}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
<TooltipProvider
delayDuration={TIMESTAMP_TOOLTIP_DELAY_MS}
skipDelayDuration={0}
>
<Tooltip>
<TooltipTrigger asChild>
<p className="shrink-0 cursor-default whitespace-nowrap text-xs font-normal leading-4 tabular-nums text-muted-foreground/55">
{time}
</p>
</TooltipTrigger>
<TooltipContent side="top">
{formatFullDateTime(createdAt)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -35,7 +35,7 @@ type UserProfilePopoverProps = {
botIdenticonValue?: string;
};
const HOVER_OPEN_DELAY_MS = 300;
const HOVER_OPEN_DELAY_MS = 500;
const HOVER_CLOSE_DELAY_MS = 200;
const RUNTIME_LABELS: Record<string, string> = {
+9
View File
@@ -1891,6 +1891,15 @@
contain-intrinsic-size: auto 200px;
}
.content-visibility-auto-interactive {
content-visibility: auto;
contain-intrinsic-size: auto 200px;
}
.content-visibility-auto-interactive:is(:hover, :focus-within) {
content-visibility: visible;
}
/* Compact variant for short single-line rows (sidebar channels, etc). */
.content-visibility-auto-row {
content-visibility: auto;
@@ -207,7 +207,7 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => {
await expect(
page.getByTestId("message-thread-replies").getByTestId("message-row"),
).toHaveCount(2);
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(2);
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1);
await screenshotThreadPanel(page, `${SHOTS}/01-baseline-depth-2.png`);
});
@@ -344,7 +344,7 @@ test.describe("thread reply anchor A/B roleplay screenshots", () => {
await expect(
page.getByTestId("message-thread-replies").getByTestId("message-row"),
).toHaveCount(2);
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(2);
await expect(page.getByTestId("thread-collapse-rail")).toHaveCount(1);
await screenshotThreadPanel(page, `${SHOTS}/04-agent-only-nested.png`);
});
+10 -15
View File
@@ -365,23 +365,18 @@ test.describe("thread unread indicator", () => {
await divider.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
const panel = page.getByTestId("message-thread-panel");
await page.getByTestId("message-thread-head").scrollIntoViewIfNeeded();
await page
.locator(
await expect(
panel.locator(
`[data-testid="thread-collapse-rail"][data-thread-head-id="mock-general-welcome"]`,
)
.click();
await expect(page.getByTestId("message-thread-panel")).toBeVisible();
await expect(replies).toHaveCount(0);
const rootStack = page
.getByTestId("message-thread-replies")
.locator(
`[data-testid="message-thread-summary"][data-thread-head-id="mock-general-welcome"]`,
);
await expect(rootStack).toBeVisible();
await expect(rootStack).toContainText("6 replies");
await rootStack.click();
await expect(replies).toHaveCount(6);
),
).toHaveCount(0);
await expect(
panel.locator(
`[data-testid="thread-collapse-guide"][data-thread-head-id="mock-general-welcome"]`,
),
).toHaveCount(0);
await page
.locator(