Skip to content

Commit 3f77233

Browse files
authored
🤖 Improve message header UX with kebab menus (#317)
## Summary Improves message header UX by reducing vertical space and adding kebab menus to consolidate actions, saving significant horizontal space. ## Changes ### Header Improvements - **Reduce vertical padding** from 8px to 4px in MessageHeader (more compact) - **Add data labels** to MessageWindow components for easier testing/debugging - **Prevent line breaking** in message headers with baseline alignment - **Add icon spacing** in ModelDisplay (margin-right: 0.3em between icon and text) ### Kebab Menu Implementation Created shared `KebabMenu` component: - Uses **React Portal** to prevent clipping by parent `overflow: hidden` containers - **Position: fixed** with calculated coordinates from `getBoundingClientRect()` - **Solid background** (#1e1e1e) with clear border (#3e3e42) for readability - **Click-outside-to-close** functionality - **Saves horizontal space** by collapsing multiple actions into single ⋮ button ### Button Layout - **ASSISTANT messages**: `[Copy] [⋮ Start Here, Show Text/Markdown, Show JSON]` - **USER messages**: `[Edit] [Copy] [⋮ Show JSON]` - Changed "Copy Text" to "Copy" (text is implied) - Edit button promoted to top-level in USER messages (most common action) - Removed emojis from Edit and Start Here (visual clashing) ## Technical Details - Use `kebabMenuItems !== undefined` to determine kebab usage - Empty array = use kebab with only Show JSON - Undefined = show Show JSON as standalone button - All message types maintain consistent header height - Portal rendering prevents z-index and overflow issues ## New Files - `src/components/KebabMenu.tsx` (reusable component with 197 lines) - `src/components/KebabMenu.stories.tsx` (6 story examples) - `src/components/ModelSelector.stories.tsx` (3 story examples) ## Testing - ✅ All 603 tests pass - ✅ TypeScript compilation clean - ✅ Formatting verified --- _Generated with `cmux`_
1 parent 86aaf8e commit 3f77233

12 files changed

+480
-47
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { KebabMenu } from "./KebabMenu";
3+
import { action } from "@storybook/addon-actions";
4+
5+
const meta = {
6+
title: "Components/KebabMenu",
7+
component: KebabMenu,
8+
parameters: {
9+
layout: "padded",
10+
},
11+
tags: ["autodocs"],
12+
} satisfies Meta<typeof KebabMenu>;
13+
14+
export default meta;
15+
type Story = StoryObj<typeof meta>;
16+
17+
export const Default: Story = {
18+
args: {
19+
items: [
20+
{ label: "Edit", onClick: action("edit") },
21+
{ label: "Duplicate", onClick: action("duplicate") },
22+
{ label: "Delete", onClick: action("delete") },
23+
],
24+
},
25+
};
26+
27+
export const WithEmojis: Story = {
28+
args: {
29+
items: [
30+
{ label: "Start Here", emoji: "🎯", onClick: action("start-here") },
31+
{ label: "Show Text", onClick: action("show-text") },
32+
{ label: "Show JSON", onClick: action("show-json") },
33+
],
34+
},
35+
};
36+
37+
export const WithActiveState: Story = {
38+
args: {
39+
items: [
40+
{ label: "Show Markdown", onClick: action("show-markdown") },
41+
{ label: "Show Text", onClick: action("show-text"), active: true },
42+
{ label: "Show JSON", onClick: action("show-json") },
43+
],
44+
},
45+
};
46+
47+
export const WithDisabledItems: Story = {
48+
args: {
49+
items: [
50+
{ label: "Edit", onClick: action("edit") },
51+
{ label: "Delete", onClick: action("delete"), disabled: true },
52+
{ label: "Archive", onClick: action("archive") },
53+
],
54+
},
55+
};
56+
57+
export const WithTooltips: Story = {
58+
args: {
59+
items: [
60+
{
61+
label: "Start Here",
62+
emoji: "🎯",
63+
onClick: action("start-here"),
64+
tooltip: "Replace all chat history with this message",
65+
},
66+
{ label: "Show Text", onClick: action("show-text"), tooltip: "View raw text" },
67+
{ label: "Show JSON", onClick: action("show-json"), tooltip: "View message as JSON" },
68+
],
69+
},
70+
};
71+
72+
export const ManyItems: Story = {
73+
args: {
74+
items: [
75+
{ label: "Copy", onClick: action("copy") },
76+
{ label: "Edit", onClick: action("edit") },
77+
{ label: "Duplicate", onClick: action("duplicate") },
78+
{ label: "Archive", onClick: action("archive") },
79+
{ label: "Share", onClick: action("share") },
80+
{ label: "Export", onClick: action("export") },
81+
{ label: "Delete", onClick: action("delete"), disabled: true },
82+
],
83+
},
84+
};

src/components/KebabMenu.tsx

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import React, { useState, useRef, useEffect } from "react";
2+
import { createPortal } from "react-dom";
3+
import styled from "@emotion/styled";
4+
import { TooltipWrapper, Tooltip } from "./Tooltip";
5+
6+
const KebabButton = styled.button<{ active?: boolean }>`
7+
background: ${(props) => (props.active ? "rgba(255, 255, 255, 0.1)" : "none")};
8+
border: 1px solid rgba(255, 255, 255, 0.2);
9+
color: #cccccc;
10+
font-size: 10px;
11+
padding: 2px 8px;
12+
border-radius: 3px;
13+
cursor: pointer;
14+
transition: all 0.2s ease;
15+
font-family: var(--font-primary);
16+
display: flex;
17+
align-items: center;
18+
justify-content: center;
19+
white-space: nowrap;
20+
21+
&:hover {
22+
background: rgba(255, 255, 255, 0.1);
23+
border-color: rgba(255, 255, 255, 0.3);
24+
}
25+
26+
&:disabled {
27+
opacity: 0.5;
28+
cursor: not-allowed;
29+
}
30+
`;
31+
32+
const DropdownMenu = styled.div`
33+
position: fixed;
34+
background: #1e1e1e;
35+
border: 1px solid #3e3e42;
36+
border-radius: 3px;
37+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.8);
38+
z-index: 10000;
39+
min-width: 160px;
40+
overflow: hidden;
41+
`;
42+
43+
const MenuItem = styled.button<{ active?: boolean; disabled?: boolean }>`
44+
width: 100%;
45+
background: ${(props) => (props.active ? "rgba(255, 255, 255, 0.15)" : "#1e1e1e")};
46+
border: none;
47+
border-bottom: 1px solid #2d2d30;
48+
color: ${(props) => (props.disabled ? "#808080" : "#cccccc")};
49+
font-size: 11px;
50+
padding: 8px 12px;
51+
text-align: left;
52+
cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
53+
transition: all 0.15s ease;
54+
font-family: var(--font-primary);
55+
display: flex;
56+
align-items: center;
57+
gap: 8px;
58+
opacity: ${(props) => (props.disabled ? 0.5 : 1)};
59+
60+
&:last-child {
61+
border-bottom: none;
62+
}
63+
64+
&:hover {
65+
background: ${(props) => (props.disabled ? "#1e1e1e" : "rgba(255, 255, 255, 0.15)")};
66+
color: ${(props) => (props.disabled ? "#808080" : "#ffffff")};
67+
}
68+
`;
69+
70+
const MenuItemEmoji = styled.span`
71+
font-size: 13px;
72+
width: 16px;
73+
text-align: center;
74+
flex-shrink: 0;
75+
`;
76+
77+
const MenuItemLabel = styled.span`
78+
flex: 1;
79+
`;
80+
81+
const MenuContainer = styled.div`
82+
position: relative;
83+
`;
84+
85+
export interface KebabMenuItem {
86+
label: string;
87+
onClick: () => void;
88+
active?: boolean;
89+
disabled?: boolean;
90+
emoji?: string;
91+
tooltip?: string;
92+
}
93+
94+
interface KebabMenuProps {
95+
items: KebabMenuItem[];
96+
className?: string;
97+
}
98+
99+
/**
100+
* A kebab menu (three vertical dots) that displays a dropdown of menu items.
101+
*
102+
* Reduces header clutter by collapsing multiple actions into a single button,
103+
* saving significant horizontal space compared to individual buttons.
104+
*
105+
* Uses React Portal to render dropdown at document.body, preventing clipping
106+
* by parent containers with overflow constraints.
107+
*/
108+
export const KebabMenu: React.FC<KebabMenuProps> = ({ items, className }) => {
109+
const [isOpen, setIsOpen] = useState(false);
110+
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
111+
const buttonRef = useRef<HTMLButtonElement>(null);
112+
const menuRef = useRef<HTMLDivElement>(null);
113+
114+
// Calculate dropdown position when menu opens
115+
useEffect(() => {
116+
if (isOpen && buttonRef.current) {
117+
const rect = buttonRef.current.getBoundingClientRect();
118+
setDropdownPosition({
119+
top: rect.bottom + 4, // 4px gap below button
120+
left: rect.right - 160, // Align right edge (160px = min-width)
121+
});
122+
}
123+
}, [isOpen]);
124+
125+
// Close menu when clicking outside
126+
useEffect(() => {
127+
if (!isOpen) return;
128+
129+
const handleClickOutside = (e: MouseEvent) => {
130+
// Check both button and dropdown (which is now in portal)
131+
if (
132+
buttonRef.current &&
133+
!buttonRef.current.contains(e.target as Node) &&
134+
menuRef.current &&
135+
!menuRef.current.contains(e.target as Node)
136+
) {
137+
setIsOpen(false);
138+
}
139+
};
140+
141+
document.addEventListener("mousedown", handleClickOutside);
142+
return () => document.removeEventListener("mousedown", handleClickOutside);
143+
}, [isOpen]);
144+
145+
const handleItemClick = (item: KebabMenuItem) => {
146+
if (item.disabled) return;
147+
item.onClick();
148+
setIsOpen(false);
149+
};
150+
151+
const button = (
152+
<KebabButton
153+
ref={buttonRef}
154+
active={isOpen}
155+
onClick={() => setIsOpen(!isOpen)}
156+
className={className}
157+
>
158+
159+
</KebabButton>
160+
);
161+
162+
return (
163+
<>
164+
<MenuContainer>
165+
<TooltipWrapper inline>
166+
{button}
167+
<Tooltip align="center">More actions</Tooltip>
168+
</TooltipWrapper>
169+
</MenuContainer>
170+
171+
{isOpen &&
172+
createPortal(
173+
<DropdownMenu
174+
ref={menuRef}
175+
style={{
176+
top: `${dropdownPosition.top}px`,
177+
left: `${dropdownPosition.left}px`,
178+
}}
179+
>
180+
{items.map((item, index) => (
181+
<MenuItem
182+
key={index}
183+
active={item.active}
184+
disabled={item.disabled}
185+
onClick={() => handleItemClick(item)}
186+
title={item.tooltip}
187+
>
188+
{item.emoji && <MenuItemEmoji>{item.emoji}</MenuItemEmoji>}
189+
<MenuItemLabel>{item.label}</MenuItemLabel>
190+
</MenuItem>
191+
))}
192+
</DropdownMenu>,
193+
document.body
194+
)}
195+
</>
196+
);
197+
};

src/components/Messages/AssistantMessage.stories.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,24 @@ export const EmptyContent: Story = {
236236
message: createAssistantMessage(""),
237237
},
238238
};
239+
240+
export const LongModelName: Story = {
241+
args: {
242+
message: createAssistantMessage(
243+
"This message has a very long model name that should be truncated to show the end.",
244+
{
245+
model: "anthropic:claude-opus-4-20250514-preview-experimental",
246+
}
247+
),
248+
},
249+
};
250+
251+
export const WithKebabMenu: Story = {
252+
args: {
253+
message: createAssistantMessage(
254+
"The header now uses a kebab menu (⋮) to reduce clutter. " +
255+
"Click the three dots to see actions like 'Show Text' and 'Show JSON'. " +
256+
"The 'Copy Text' button remains visible for quick access."
257+
),
258+
},
259+
};

src/components/Messages/AssistantMessage.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { COMPACTED_EMOJI } from "@/constants/ui";
1010
import { ModelDisplay } from "./ModelDisplay";
1111
import { CompactingMessageContent } from "./CompactingMessageContent";
1212
import { CompactionBackground } from "./CompactionBackground";
13+
import type { KebabMenuItem } from "@/components/KebabMenu";
1314

1415
const RawContent = styled.pre`
1516
font-family: var(--font-monospace);
@@ -74,7 +75,6 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
7475
const {
7576
openModal,
7677
buttonLabel,
77-
buttonEmoji,
7878
disabled: startHereDisabled,
7979
modal,
8080
} = useStartHere(workspaceId, content, isCompacted);
@@ -89,26 +89,32 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
8989
}
9090
};
9191

92-
// Buttons only when not streaming
92+
// Keep only Copy button visible (most common action)
93+
// Kebab menu saves horizontal space by collapsing less-used actions into a single ⋮ button
9394
const buttons: ButtonConfig[] = isStreaming
95+
? []
96+
: [
97+
{
98+
label: copied ? "✓ Copied" : "Copy",
99+
onClick: () => void handleCopy(),
100+
},
101+
];
102+
103+
// Kebab menu items (less frequently used actions)
104+
const kebabMenuItems: KebabMenuItem[] = isStreaming
94105
? []
95106
: [
96107
// Add Start Here button if workspaceId is available and message is not already compacted
97108
...(workspaceId && !isCompacted
98109
? [
99110
{
100111
label: buttonLabel,
101-
emoji: buttonEmoji,
102112
onClick: openModal,
103113
disabled: startHereDisabled,
104114
tooltip: "Replace all chat history with this message",
105115
},
106116
]
107117
: []),
108-
{
109-
label: copied ? "✓ Copied" : "Copy Text",
110-
onClick: () => void handleCopy(),
111-
},
112118
{
113119
label: showRaw ? "Show Markdown" : "Show Text",
114120
onClick: () => setShowRaw(!showRaw),
@@ -165,6 +171,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
165171
borderColor="var(--color-assistant-border)"
166172
message={message}
167173
buttons={buttons}
174+
kebabMenuItems={kebabMenuItems}
168175
className={className}
169176
backgroundEffect={isStreamingCompaction ? <CompactionBackground /> : undefined}
170177
>

0 commit comments

Comments
 (0)