Skip to content

Commit 5570929

Browse files
authored
Improve messages layouts on narrow screens (#438)
## Summary - Tighten thinking block action spacing so it matches adjacent action groups. - Collapse secondary message, thinking, and tool-call actions into a Kobalte overflow menu only at the session-center narrow width stop. - Keep single-action cases inline, preserve primary/status controls, mirror delete hover overlays from menu items, and square the overflow menu styling. ## Validation - npm run typecheck --workspace @codenomad/ui
1 parent 533ccac commit 5570929

14 files changed

Lines changed: 495 additions & 29 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { DropdownMenu } from "@kobalte/core/dropdown-menu"
2+
import { For, Show, createSignal, onCleanup, type JSXElement } from "solid-js"
3+
import { MoreHorizontal } from "lucide-solid"
4+
5+
export interface ActionOverflowMenuItem {
6+
key: string
7+
label: string
8+
icon?: JSXElement
9+
disabled?: boolean
10+
destructive?: boolean
11+
onSelect: () => void | Promise<void>
12+
onMouseEnter?: () => void
13+
onMouseLeave?: () => void
14+
}
15+
16+
interface ActionOverflowMenuProps {
17+
items: ActionOverflowMenuItem[]
18+
label: string
19+
triggerClass?: string
20+
minItems?: number
21+
}
22+
23+
export default function ActionOverflowMenu(props: ActionOverflowMenuProps) {
24+
const [hoveredItem, setHoveredItem] = createSignal<ActionOverflowMenuItem | null>(null)
25+
const enabledItems = () => props.items.filter((item) => !item.disabled)
26+
const hasItems = () => props.items.length >= (props.minItems ?? 1)
27+
const clearHoveredItem = () => {
28+
const item = hoveredItem()
29+
if (!item) return
30+
item.onMouseLeave?.()
31+
setHoveredItem(null)
32+
}
33+
34+
onCleanup(clearHoveredItem)
35+
36+
return (
37+
<Show when={hasItems()}>
38+
<DropdownMenu placement="bottom-end" gutter={4} onOpenChange={(open) => { if (!open) clearHoveredItem() }}>
39+
<DropdownMenu.Trigger
40+
class={`action-overflow-trigger ${props.triggerClass ?? ""}`.trim()}
41+
aria-label={props.label}
42+
title={props.label}
43+
disabled={enabledItems().length === 0}
44+
>
45+
<MoreHorizontal class="w-3.5 h-3.5" aria-hidden="true" />
46+
</DropdownMenu.Trigger>
47+
48+
<DropdownMenu.Portal>
49+
<DropdownMenu.Content class="action-overflow-content">
50+
<For each={props.items}>
51+
{(item) => (
52+
<DropdownMenu.Item
53+
class="action-overflow-item"
54+
data-destructive={item.destructive ? "true" : undefined}
55+
disabled={item.disabled}
56+
onPointerEnter={() => {
57+
if (item.disabled) return
58+
const previous = hoveredItem()
59+
if (previous !== item) previous?.onMouseLeave?.()
60+
setHoveredItem(item)
61+
item.onMouseEnter?.()
62+
}}
63+
onPointerLeave={() => {
64+
if (item.disabled) return
65+
if (hoveredItem() === item) setHoveredItem(null)
66+
item.onMouseLeave?.()
67+
}}
68+
onSelect={() => {
69+
clearHoveredItem()
70+
void item.onSelect()
71+
}}
72+
>
73+
<Show when={item.icon} fallback={<span class="action-overflow-item-icon" aria-hidden="true" />}>
74+
{(icon) => <span class="action-overflow-item-icon" aria-hidden="true">{icon()}</span>}
75+
</Show>
76+
<span class="action-overflow-item-label">{item.label}</span>
77+
</DropdownMenu.Item>
78+
)}
79+
</For>
80+
</DropdownMenu.Content>
81+
</DropdownMenu.Portal>
82+
</DropdownMenu>
83+
</Show>
84+
)
85+
}

packages/ui/src/components/message-block.tsx

Lines changed: 155 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { For, Index, Match, Show, Suspense, Switch, createEffect, createMemo, createSignal, lazy, onCleanup, untrack, type Accessor } from "solid-js"
2-
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash } from "lucide-solid"
2+
import { ChevronsDownUp, ChevronsUpDown, ExternalLink, FoldVertical, ListStart, Trash, Volume2 } from "lucide-solid"
33
import MessageItem from "./message-item"
44
import type { InstanceMessageStore } from "../stores/message-v2/instance-store"
55
import type { ClientPart, MessageInfo } from "../types/message"
@@ -18,6 +18,7 @@ import { useSpeech } from "../lib/hooks/use-speech"
1818
import SpeechActionButton from "./speech-action-button"
1919
import { createFollowScroll } from "../lib/follow-scroll"
2020
import type { SessionSearchMatch } from "../lib/session-search"
21+
import ActionOverflowMenu, { type ActionOverflowMenuItem } from "./action-overflow-menu"
2122

2223
function DeleteUpToIcon() {
2324
return (
@@ -486,6 +487,12 @@ function ToolCallItem(props: ToolCallItemProps) {
486487
navigateToTaskSession(location)
487488
}
488489

490+
const goToTaskSession = () => {
491+
const location = taskLocation()
492+
if (!location) return
493+
navigateToTaskSession(location)
494+
}
495+
489496
const handleDeleteMessage = async (event: MouseEvent) => {
490497
event.preventDefault()
491498
event.stopPropagation()
@@ -507,9 +514,7 @@ function ToolCallItem(props: ToolCallItemProps) {
507514
}
508515
}
509516

510-
const handleDeleteUpTo = async (event: MouseEvent) => {
511-
event.preventDefault()
512-
event.stopPropagation()
517+
const deleteUpTo = async () => {
513518
if (!props.showDeleteMessage) return
514519
if (!props.onDeleteMessagesUpTo) return
515520
if (deletingUpTo()) return
@@ -522,11 +527,72 @@ function ToolCallItem(props: ToolCallItemProps) {
522527
}
523528
}
524529

530+
const handleDeleteUpTo = async (event: MouseEvent) => {
531+
event.preventDefault()
532+
event.stopPropagation()
533+
await deleteUpTo()
534+
}
535+
536+
const actionMenuItems = (): ActionOverflowMenuItem[] => {
537+
const items: ActionOverflowMenuItem[] = []
538+
539+
if (taskSessionId()) {
540+
items.push({
541+
key: "go-to-session",
542+
label: t("messageBlock.tool.goToSession.label"),
543+
icon: <ExternalLink class="w-3.5 h-3.5" aria-hidden="true" />,
544+
disabled: !taskLocation(),
545+
onSelect: goToTaskSession,
546+
})
547+
}
548+
549+
if (props.showDeleteMessage) {
550+
items.push(
551+
{
552+
key: "delete-up-to",
553+
label: t("messageItem.actions.deleteMessagesUpTo"),
554+
icon: <DeleteUpToIcon />,
555+
disabled: !props.onDeleteMessagesUpTo || deletingUpTo(),
556+
destructive: true,
557+
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId }),
558+
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
559+
onSelect: deleteUpTo,
560+
},
561+
{
562+
key: "delete-message",
563+
label: deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage"),
564+
icon: <Trash class="w-3.5 h-3.5" aria-hidden="true" />,
565+
disabled: deletingMessage(),
566+
destructive: true,
567+
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId }),
568+
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
569+
onSelect: async () => {
570+
if (deletingMessage()) return
571+
setDeletingMessage(true)
572+
try {
573+
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
574+
} catch (error) {
575+
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
576+
title: t("messageItem.actions.deleteMessageFailedTitle"),
577+
detail: error instanceof Error ? error.message : String(error),
578+
variant: "error",
579+
})
580+
} finally {
581+
setDeletingMessage(false)
582+
}
583+
},
584+
},
585+
)
586+
}
587+
588+
return items
589+
}
590+
525591
return (
526592
<Show when={toolPart()}>
527593
{(resolvedToolPart) => (
528594
<div class="delete-hover-scope" data-delete-part-hover={isDeleteOverlayActive() ? "true" : undefined}>
529-
<div class="tool-call-header-label">
595+
<div class="tool-call-header-label" data-action-overflow={actionMenuItems().length > 1 ? "true" : undefined}>
530596
<div class="tool-call-header-meta">
531597
<Show when={props.showDeleteMessage}>
532598
<input
@@ -551,7 +617,7 @@ function ToolCallItem(props: ToolCallItemProps) {
551617
<span class="tool-name">{toolName() || t("messageBlock.tool.unknown")}</span>
552618
</div>
553619

554-
<div class="flex items-center gap-0">
620+
<div class="tool-call-header-actions flex items-center gap-0">
555621
<Show when={taskSessionId()}>
556622
<button
557623
class="tool-call-header-button"
@@ -593,6 +659,12 @@ function ToolCallItem(props: ToolCallItemProps) {
593659
</button>
594660
</Show>
595661
</div>
662+
<ActionOverflowMenu
663+
items={actionMenuItems()}
664+
label={t("messageItem.actions.more")}
665+
triggerClass="tool-call-header-button"
666+
minItems={2}
667+
/>
596668
</div>
597669

598670
<Suspense fallback={<ToolCallFallback />}>
@@ -1582,6 +1654,73 @@ function ReasoningCard(props: ReasoningCardProps) {
15821654

15831655
const canDeleteMessage = () => Boolean(props.showDeleteMessage) && !deletingMessage()
15841656

1657+
const deleteUpTo = async () => {
1658+
if (!props.showDeleteMessage) return
1659+
if (!props.onDeleteMessagesUpTo) return
1660+
if (deletingUpTo()) return
1661+
1662+
setDeletingUpTo(true)
1663+
try {
1664+
await props.onDeleteMessagesUpTo(props.messageId)
1665+
} finally {
1666+
setDeletingUpTo(false)
1667+
}
1668+
}
1669+
1670+
const actionMenuItems = (): ActionOverflowMenuItem[] => {
1671+
const items: ActionOverflowMenuItem[] = []
1672+
1673+
if (canSpeakReasoning()) {
1674+
items.push({
1675+
key: "speak",
1676+
label: speech.buttonTitle(),
1677+
icon: <Volume2 class="w-3.5 h-3.5" aria-hidden="true" />,
1678+
onSelect: () => void speech.toggle(),
1679+
})
1680+
}
1681+
1682+
if (props.showDeleteMessage) {
1683+
items.push(
1684+
{
1685+
key: "delete-up-to",
1686+
label: t("messageItem.actions.deleteMessagesUpTo"),
1687+
icon: <DeleteUpToIcon />,
1688+
disabled: !props.onDeleteMessagesUpTo || deletingUpTo(),
1689+
destructive: true,
1690+
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "deleteUpTo", messageId: props.messageId }),
1691+
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
1692+
onSelect: deleteUpTo,
1693+
},
1694+
{
1695+
key: "delete-message",
1696+
label: deletingMessage() ? t("messageItem.actions.deletingMessage") : t("messageItem.actions.deleteMessage"),
1697+
icon: <Trash class="w-3.5 h-3.5" aria-hidden="true" />,
1698+
disabled: !canDeleteMessage(),
1699+
destructive: true,
1700+
onMouseEnter: () => props.onDeleteHoverChange?.({ kind: "message", messageId: props.messageId }),
1701+
onMouseLeave: () => props.onDeleteHoverChange?.({ kind: "none" }),
1702+
onSelect: async () => {
1703+
if (!canDeleteMessage()) return
1704+
setDeletingMessage(true)
1705+
try {
1706+
await deleteMessage(props.instanceId, props.sessionId, props.messageId)
1707+
} catch (error) {
1708+
showAlertDialog(t("messageItem.actions.deleteMessageFailedMessage"), {
1709+
title: t("messageItem.actions.deleteMessageFailedTitle"),
1710+
detail: error instanceof Error ? error.message : String(error),
1711+
variant: "error",
1712+
})
1713+
} finally {
1714+
setDeletingMessage(false)
1715+
}
1716+
},
1717+
},
1718+
)
1719+
}
1720+
1721+
return items
1722+
}
1723+
15851724
const handleDeleteMessage = async (event: MouseEvent) => {
15861725
event.preventDefault()
15871726
event.stopPropagation()
@@ -1604,16 +1743,7 @@ function ReasoningCard(props: ReasoningCardProps) {
16041743
const handleDeleteUpTo = async (event: MouseEvent) => {
16051744
event.preventDefault()
16061745
event.stopPropagation()
1607-
if (!props.showDeleteMessage) return
1608-
if (!props.onDeleteMessagesUpTo) return
1609-
if (deletingUpTo()) return
1610-
1611-
setDeletingUpTo(true)
1612-
try {
1613-
await props.onDeleteMessagesUpTo(props.messageId)
1614-
} finally {
1615-
setDeletingUpTo(false)
1616-
}
1746+
await deleteUpTo()
16171747
}
16181748

16191749
return (
@@ -1654,7 +1784,7 @@ function ReasoningCard(props: ReasoningCardProps) {
16541784
</span>
16551785
</button>
16561786

1657-
<div class="message-reasoning-actions">
1787+
<div class="message-reasoning-actions" data-action-overflow={actionMenuItems().length > 1 ? "true" : undefined}>
16581788
<Show when={canSpeakReasoning()}>
16591789
<SpeechActionButton
16601790
class="message-action-button"
@@ -1671,7 +1801,7 @@ function ReasoningCard(props: ReasoningCardProps) {
16711801

16721802
<button
16731803
type="button"
1674-
class="message-action-button"
1804+
class="message-action-button message-reasoning-primary-action"
16751805
onClick={(event) => {
16761806
event.preventDefault()
16771807
event.stopPropagation()
@@ -1713,6 +1843,13 @@ function ReasoningCard(props: ReasoningCardProps) {
17131843
</button>
17141844
</Show>
17151845

1846+
<ActionOverflowMenu
1847+
items={actionMenuItems()}
1848+
label={t("messageItem.actions.more")}
1849+
triggerClass="message-action-button"
1850+
minItems={2}
1851+
/>
1852+
17161853
<span class="message-reasoning-time">{timestamp()}</span>
17171854
</div>
17181855
</div>

0 commit comments

Comments
 (0)