Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5AZ2FXC4FD834XGK86MA2NC
anchor: function Presence
created: 2026-10-19T20:53:56Z
norm: '1'
sig: 5b691c6498a6be05
body_hash: 6f560726b2f43907
raw_hash: 533cb5f940611bea
lines: 58-90
---

the facepile sits inside the row's button, so its trigger keeps an empty title or the row's summary tooltip covers the presence tooltip
4 changes: 0 additions & 4 deletions packages/ui/src/components/session-item/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,3 @@ export type ActionProps = {
export function canTogglePin({ pinned, onPin, onUnpin }: ActionProps) {
return pinned ? Boolean(onUnpin) : Boolean(onPin);
}

export function hasVisibleActions(actions: ActionProps) {
return canTogglePin(actions) || Boolean(actions.onArchive);
}
96 changes: 1 addition & 95 deletions packages/ui/src/components/session-item/session-item-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { type ActionProps, canTogglePin, hasVisibleActions } from "./actions";
import { type ActionProps, canTogglePin } from "./actions";
import type { MouseEvent, ReactNode } from "react";
import { useRef, useState } from "react";
import { ContextMenu } from "@base-ui/react/context-menu";

import { hitArea } from "../../lib/hit-area";
import { cn } from "../../lib/utils";
import { motion } from "../../ui/popup";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";

import type { MoveTarget, SidebarRow } from "./session-item.types";
import {
Expand Down Expand Up @@ -35,20 +32,6 @@ const MENU_ITEM_DESTRUCTIVE =
"text-destructive focus:bg-destructive/10 focus:text-destructive dark:focus:bg-destructive/20";
const MENU_ITEM_DESTRUCTIVE_FULL = `${MENU_ITEM} ${MENU_ITEM_DESTRUCTIVE}`;
const MENU_SEPARATOR = "-mx-1 my-1 h-px bg-border/50";
// The enlarged hit area spills into neighbouring rows, so only the hovered row's may catch presses.
const ACTION_BUTTON = cn(
hitArea,
"before:pointer-events-none group-hover/row:before:pointer-events-auto",
"grid size-6 place-items-center rounded-md squircle text-muted-foreground outline-2 outline-offset-[-1px] outline-transparent transition-[background-color,color,transform] duration-150 ease-out hover:bg-background/70 hover:text-foreground group-data-[primary=true]/menu:active:translate-y-px focus-visible:outline-ring/50",
);
// Hit-testing must not wait for :hover. A press that arrives before hover styles apply lands on the
// row, its release lands on the button, and the click then reaches neither.
const SESSION_ACTIONS =
"inline-flex items-center gap-0 -space-x-0.5 opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none group-hover/row:opacity-100 group-focus-within/row:opacity-100 group-data-[menu-open=true]/menu:opacity-100";

type SessionActionsProps = ActionProps & {
data: SidebarRow;
};

type RowMenuProps = ActionProps & {
data: SidebarRow;
Expand Down Expand Up @@ -82,83 +65,6 @@ function stop(event: MouseEvent<HTMLElement>) {
event.stopPropagation();
}

function ActionButton(
{
label,
children,
onClick,
}: {
label: string;
children: ReactNode;
onClick?: (event: MouseEvent<HTMLButtonElement>) => void;
},
) {
return (
<Tooltip>
<TooltipTrigger
render={
<button
type="button"
aria-label={label}
className={ACTION_BUTTON}
onClick={onClick}
/>
}
>
{children}
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{label}
</TooltipContent>
</Tooltip>
);
}

export function SessionActions(
{
data,
pinned,
onPin,
onUnpin,
onArchive,
}: SessionActionsProps,
) {
let canPin = canTogglePin({ pinned, onPin, onUnpin, onArchive });
if (!hasVisibleActions({ pinned, onPin, onUnpin, onArchive })) return null;

function pin(event: MouseEvent<HTMLButtonElement>) {
stop(event);
if (pinned) onUnpin?.(data);
else onPin?.(data);
}

function archive(event: MouseEvent<HTMLElement>) {
stop(event);
onArchive?.(data);
}

return (
<span className={SESSION_ACTIONS}>
{canPin && (
<ActionButton label={pinned ? "Unpin channel" : "Pin channel"} onClick={pin}>
{pinned ? <PinSlash className="size-3.5" /> : <Pin className="size-3.5" />}
</ActionButton>
)}
{onArchive && (data.lifecycle === "archived"
? (
<ActionButton label="Unarchive channel" onClick={archive}>
<Unarchive className="size-3.5" />
</ActionButton>
)
: (
<ActionButton label="Archive channel" onClick={archive}>
<ArchiveContent2 className="size-3.5" />
</ActionButton>
))}
</span>
);
}

function MoveSection(
{ destinations, moving, onMove }: {
destinations: Destinations;
Expand Down
54 changes: 11 additions & 43 deletions packages/ui/src/components/session-item/session-item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ import { Facepile } from "../facepile/facepile";
import { cn } from "../../lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";

import { RowMenu, SessionActions } from "./session-item-actions";
import { hasVisibleActions } from "./actions";
import { RowMenu } from "./session-item-actions";
import { RowGlyph } from "./session-item-glyph";
import type { MoveTarget, SidebarRow } from "./session-item.types";
import {
Expand All @@ -23,11 +22,6 @@ const EXPANDED_CONTENT_PADDING_BOTTOM =
"data-[open=true]:pb-(--session-item-expanded-padding-bottom)";
const TITLE_TO_EXPANDED_CONTENT_GAP = "data-[open=true]:mt-(--session-item-title-to-content-gap)";
const EXPANDED_CONTENT_ROW_GAP = "gap-y-1";
// The facepile and the actions sit side by side so pointing at a face never reaches pin, and
// neither moves on hover. The 8px gap equals the spill of the pin's 40px hit area around its 24px
// button, so that hit area ends at the facepile's edge; the facepile also stacks above it.
const TRAILING =
"pointer-events-none absolute top-1/2 right-1 z-20 inline-flex -translate-y-1/2 items-center gap-2 *:pointer-events-auto";

export type SessionItemProps = ComponentPropsWithRef<"button"> & {
data: SidebarRow;
Expand Down Expand Up @@ -72,6 +66,9 @@ export function Presence({ members }: { members: SidebarRow["online"] }) {
render={
<span
aria-label={`Here: ${visible.map((member) => member.alt).join(", ")}`}
// The facepile sits inside the row's button; an empty title keeps the row's summary
// tooltip from covering this one.
title=""
className="shrink-0 rounded-full"
/>
}
Expand Down Expand Up @@ -232,12 +229,7 @@ function expandedBody(data: SidebarRow, content: SessionItemProps["expandedConte
return typeof content === "function" ? content(data) : content ?? defaultExpandedContent(data);
}

function buttonClass(
hoverExpand: boolean | undefined,
hasActions: boolean,
online: number,
className?: string,
) {
function buttonClass(hoverExpand: boolean | undefined, className?: string) {
return cn(
"relative flex h-[calc(var(--spacing)*8-2px)] w-full px-1 text-left",
hoverExpand ? "flex-col items-stretch justify-center gap-0" : "items-center gap-1.5",
Expand All @@ -247,14 +239,9 @@ function buttonClass(
"data-[status=active]:selected-surface",
"data-[status=active]:text-accent-text data-[status=active]:font-medium",
hoverExpand
? "transition-[background-color,filter,padding-right] duration-150 ease-out motion-reduce:transition-none data-[expanded=true]:rounded-b-none data-[expanded=true]:bg-popover data-[expanded=true]:hover:bg-popover"
: "transition-[padding-right] duration-150 ease-out motion-reduce:transition-none",
&& "transition-[background-color,filter] duration-150 ease-out motion-reduce:transition-none data-[expanded=true]:rounded-b-none data-[expanded=true]:bg-popover data-[expanded=true]:hover:bg-popover",
"group-data-[primary=true]/menu:active:translate-y-px group-data-[primary=true]/menu:active:brightness-95",
"outline-2 outline-offset-0 outline-transparent focus-visible:outline-ring/50",
hasActions
&& online === 0
&& "group-hover/row:pr-13 group-focus-within/row:pr-13 group-data-[menu-open=true]/menu:pr-13",
online > 0 && (hasActions ? "pr-24" : "pr-17"),
className,
);
}
Expand Down Expand Up @@ -290,7 +277,7 @@ export function SessionItem(
...props
}: SessionItemProps,
) {
let { onPin: actionPin, onUnpin: actionUnpin, ...menu } = actions(data, {
let menu = actions(data, {
onPin,
onUnpin,
onFork,
Expand All @@ -304,7 +291,6 @@ export function SessionItem(
onMove,
onCompute,
});
let hasActions = hasVisibleActions({ pinned, onPin: actionPin, onUnpin: actionUnpin });
let [hovered, setHovered] = useState(false);
let timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
let content = hoverExpand ? expandedBody(data, expandedContent) : null;
Expand All @@ -320,7 +306,8 @@ export function SessionItem(
<span className="@container grid size-6 place-items-center shrink-0 text-muted-foreground group-data-[status=active]/row:text-current/80 [&_svg]:size-4.5 [&_svg]:shrink-0">
<RowGlyph data={data} selected={selected} />
</span>
<span className="min-w-0 flex-1 truncate">{data.name}</span>
<span className="min-w-0 truncate">{data.name}</span>
<Presence members={data.online} />
</>
);

Expand All @@ -342,13 +329,7 @@ export function SessionItem(
}, []);

return (
<RowMenu
data={data}
pinned={pinned}
onPin={actionPin}
onUnpin={actionUnpin}
{...menu}
>
<RowMenu data={data} pinned={pinned} {...menu}>
<div
data-status={status}
data-lifecycle={data.lifecycle}
Expand Down Expand Up @@ -383,7 +364,7 @@ export function SessionItem(
title={preview}
onMouseEnter={enter}
onMouseLeave={leave}
className={buttonClass(hoverExpand, hasActions, data.online.length, className)}
className={buttonClass(hoverExpand, className)}
{...props}
>
{hoverExpand
Expand Down Expand Up @@ -411,19 +392,6 @@ export function SessionItem(
)
: line}
</button>
<span className={TRAILING}>
{data.online.length > 0 && (
<span className="relative z-1 inline-flex">
<Presence members={data.online} />
</span>
)}
<SessionActions
data={data}
pinned={pinned}
onPin={actionPin}
onUnpin={actionUnpin}
/>
</span>
</div>
</RowMenu>
);
Expand Down