fix(site/src/pages/AgentsPage): preserve chat row width with menu open (#29422)

Opening a chat row's actions menu could leave the row inset with rounded
corners after the pointer moved into the portaled menu.

Apply the same full-width layout used on hover while the menu is open,
keeping the row edge-to-edge until the menu closes.

Linear:
https://linear.app/codercom/issue/CODAGT-1040/new-chat-sidebar-row-changes-from-full-width-to-pill-shape-on-hover

<details>
<summary>Coder Agents implementation notes</summary>

- Reproduce the visual state using the existing sidebar menu Storybook
story.
- Preserve the chat row's hover geometry whenever its dropdown trigger
reports `data-state="open"`.
- Verify with the sidebar Storybook suite, frontend typecheck,
repository hooks, and visual inspection.

</details>

_Generated by Coder Agents on behalf of @tracyjohnsonux._
This commit is contained in:
TJ
2026-09-27 15:02:06 +10:00
committed by GitHub
parent 93a00bd515
commit 2c1e888d6c
2 changed files with 63 additions and 14 deletions
@@ -1752,6 +1752,33 @@ export const ActiveChatKebabPersistent: Story = {
},
};
export const ActiveChatContextMenuOpen: Story = {
args: {
chats: [
buildChat({
id: "active-context-menu",
title: "Active context menu chat",
updated_at: recentTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/agents/active-context-menu",
pathParams: { agentId: "active-context-menu" },
},
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
fireEvent.contextMenu(
within(canvasElement).getByTestId("agents-tree-node-active-context-menu"),
);
await within(document.body).findByText("Pin agent");
},
};
export const WithUnreadChats: Story = {
args: {
chats: [
@@ -1848,6 +1875,33 @@ export const AgentWithWorkspaceMenuFull: Story = {
},
};
export const AgentWithWorkspaceContextMenuFull: Story = {
args: {
chats: [
buildChat({
id: "chat-with-context-menu",
title: "Agent with context menu",
workspace_id: "workspace-1",
updated_at: recentTimestamp,
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
play: async ({ canvasElement }) => {
fireEvent.contextMenu(
within(canvasElement).getByTestId(
"agents-tree-node-chat-with-context-menu",
),
);
await within(document.body).findByText("Pin agent");
},
};
export const ArchiveActionsFollowChatStatus: Story = {
args: {
chats: [
@@ -155,10 +155,6 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, depth = 0 }) => {
hasSubagentsToggle: hasChildren,
});
const hoverLayout =
"[@media(hover:hover)]:hover:-mx-2 [@media(hover:hover)]:hover:pl-3 [@media(hover:hover)]:hover:pr-3.5 [@media(hover:hover)]:hover:rounded-none";
const activeLayout =
"has-[[aria-current=page]]:-mx-2 has-[[aria-current=page]]:pl-[11px] has-[[aria-current=page]]:pr-3.5 has-[[aria-current=page]]:rounded-none has-[[aria-current=page]]:border-l has-[[aria-current=page]]:border-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:pl-[11px]";
const sharedMenuItemProps = {
chat,
canManage,
@@ -189,11 +185,10 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, depth = 0 }) => {
<div
data-testid={`agents-tree-node-${chat.id}`}
className={cn(
"group relative flex min-w-0 select-none pointer-coarse:[-webkit-touch-callout:none] items-start gap-1.5 rounded-md pl-1 pr-1.5 text-content-secondary",
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary has-data-[state=open]:bg-surface-tertiary",
"has-[[aria-current=page]]:bg-surface-quaternary/50 has-[[aria-current=page]]:text-content-primary [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
hoverLayout,
activeLayout,
"group relative -mx-2 flex min-w-0 select-none pointer-coarse:[-webkit-touch-callout:none] items-start gap-1.5 rounded-none pl-3 pr-3.5 text-content-secondary",
"transition-none [@media(hover:hover)]:hover:bg-surface-tertiary/50 [@media(hover:hover)]:hover:text-content-primary data-[state=open]:bg-surface-tertiary/50 data-[state=open]:text-content-primary has-data-[state=open]:bg-surface-tertiary/50 has-data-[state=open]:text-content-primary",
// pl-[11px] is pl-3 minus the active border, so content does not shift.
"has-[[aria-current=page]]:border-l has-[[aria-current=page]]:border-content-primary has-[[aria-current=page]]:bg-surface-quaternary/50 has-[[aria-current=page]]:pl-[11px] has-[[aria-current=page]]:text-content-primary data-[state=open]:has-[[aria-current=page]]:bg-surface-quaternary/50 has-data-[state=open]:has-[[aria-current=page]]:bg-surface-quaternary/50 [@media(hover:hover)]:has-[[aria-current=page]]:hover:bg-surface-quaternary/50",
)}
>
<div
@@ -254,7 +249,7 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, depth = 0 }) => {
className={cn(
"block flex-1 truncate text-[13px] text-content-primary",
!isActive &&
"opacity-85 [@media(hover:hover)]:group-hover:opacity-100",
"opacity-85 [@media(hover:hover)]:group-hover:opacity-100 group-data-[state=open]:opacity-100 group-has-data-[state=open]:opacity-100",
)}
>
{chat.title}
@@ -311,10 +306,10 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, depth = 0 }) => {
className={cn(
"flex items-center justify-end text-xs text-content-secondary/50 tabular-nums",
// The timestamp swaps out for the actions trigger on
// hover; without menu actions there is no trigger, so
// keep the timestamp visible.
// hover or while a menu is open. Without menu actions,
// there is no trigger, so keep the timestamp visible.
hasMenuActions &&
"[@media(hover:hover)]:group-hover:hidden group-has-data-[state=open]:hidden",
"[@media(hover:hover)]:group-hover:hidden group-data-[state=open]:hidden group-has-data-[state=open]:hidden",
hasMenuActions && isActiveChat && "hidden",
)}
>
@@ -353,7 +348,7 @@ export const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, depth = 0 }) => {
size="icon"
variant="subtle"
className={cn(
"absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100",
"absolute inset-0 flex h-6 w-7 min-w-0 justify-end rounded-none px-0 opacity-0 text-content-secondary hover:text-content-primary [@media(hover:hover)]:group-hover:opacity-100 data-[state=open]:opacity-100 group-data-[state=open]:opacity-100",
isActiveChat && "opacity-100",
)}
aria-label={`Open actions for ${chat.title}`}