fix: show tablet actions without hover

Makes hover-only controls visible on tablets
Preserves existing mobile and desktop layouts
Covers chat, session sidebar, file, tab, and picker actions
This commit is contained in:
Bohdan Triapitsyn
2026-05-03 17:19:55 +03:00
parent 43f98e0344
commit 6595322f1a
20 changed files with 164 additions and 68 deletions
@@ -149,7 +149,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
animateUserOnMount = false,
onUserAnimationConsumed,
}) => {
const { isMobile, hasTouchInput } = useDeviceInfo();
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
const alwaysShowMessageActions = isMobile || isTablet;
const { currentTheme } = useThemeSystem();
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
@@ -1030,8 +1031,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
@@ -1063,8 +1065,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={expandedTools}
@@ -1115,8 +1118,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
messageCompletedAt={messageCompletedAt ?? undefined}
messageCreatedAt={messageCreatedAt ?? undefined}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
hasTouchInput={hasTouchInput}
copiedCode={copiedCode}
onCopyCode={handleCopyCode}
expandedTools={effectiveExpandedTools}
@@ -10,6 +10,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip
import { useIsVSCodeRuntime } from '@/hooks/useRuntimeAPIs';
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import type { ToolPopupContent } from './message/types';
@@ -127,6 +128,8 @@ interface ImagePreviewProps {
const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
const { t } = useI18n();
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
const isLocalImagePreview =
file.source !== 'server' &&
file.mimeType.startsWith('image/') &&
@@ -184,7 +187,10 @@ const ImagePreview = memo(({ file, onRemove }: ImagePreviewProps) => {
/>
<button
onClick={onRemove}
className="absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className={cn(
"absolute top-0.5 right-0.5 h-4 w-4 rounded-full bg-background/80 text-foreground hover:text-destructive flex items-center justify-center transition-opacity focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}
title={t('chat.fileAttachment.actions.removeImage')}
aria-label={t('chat.fileAttachment.actions.removeNamed', { name: displayName })}
>
@@ -311,10 +311,15 @@ const TableDownloadButton: React.FC<{ tableRef: React.RefObject<HTMLDivElement |
// Table wrapper with custom controls
const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }> = ({ children, className }) => {
const tableRef = React.useRef<HTMLDivElement>(null);
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
return (
<div className="group my-4 flex flex-col space-y-2" data-markdown="table-wrapper" ref={tableRef}>
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<div className={cn(
"flex items-center justify-end gap-1 transition-opacity",
alwaysShowActions ? "opacity-100" : "opacity-0 group-hover:opacity-100"
)}>
<TableCopyButton tableRef={tableRef} />
<TableDownloadButton tableRef={tableRef} />
</div>
@@ -330,7 +335,7 @@ const TableWrapper: React.FC<{ children?: React.ReactNode; className?: string }>
const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ source, mode }) => {
const { t } = useI18n();
const currentTheme = useCurrentMermaidTheme();
const { isMobile } = useDeviceInfo();
const { isMobile, isTablet } = useDeviceInfo();
const [copied, setCopied] = React.useState(false);
const [downloaded, setDownloaded] = React.useState(false);
@@ -362,7 +367,7 @@ const MermaidBlock: React.FC<{ source: string; mode: 'svg' | 'ascii' }> = ({ sou
}
}, [mode, source]);
const copyVisibilityClass = isMobile ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
const copyVisibilityClass = isMobile || isTablet ? 'opacity-100' : 'opacity-0 group-hover:opacity-100';
const handleCopyAscii = async (asciiText: string) => {
if (!asciiText) return;
@@ -724,6 +729,7 @@ const MarkdownCodeBlock: React.FC<{
const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code');
const prevCodeRef = React.useRef<string>(code);
const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
const { isMobile, isTablet } = useDeviceInfo();
const canPreview = language === 'html' || language === 'htm';
@@ -774,7 +780,10 @@ const MarkdownCodeBlock: React.FC<{
<div data-component="markdown-code" className="my-4 group overflow-hidden rounded-2xl border border-border/80 bg-[var(--surface-elevated)]">
<div className="flex items-center justify-between border-b border-border/70 px-3 py-1.5">
<span className="font-mono text-[13px] text-muted-foreground">{language}</span>
<div className="flex items-center gap-1 opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
<div className={cn(
"flex items-center gap-1 transition-opacity",
isMobile || isTablet ? "opacity-100" : "opacity-100 md:opacity-0 md:group-hover:opacity-100"
)}>
{canPreview ? (
<button
type="button"
@@ -426,7 +426,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
const [isAgentSelectorOpen, setIsAgentSelectorOpen] = React.useState(false);
const { favoriteModelsList, recentModelsList } = useModelLists();
const { isMobile } = useDeviceInfo();
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowHoverDetails = isMobile || isTablet;
const isDesktop = React.useMemo(() => isDesktopShell(), []);
const isVSCodeRuntime = useIsVSCodeRuntime();
// Only use mobile panels on actual mobile devices, VSCode uses desktop dropdowns
@@ -2378,7 +2379,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
) : slides.length > 0 ? (
<div className={cn(
"items-center",
shouldAnimate ? "flex w-[140px] justify-end" : ((isHighlighted || isSelected) ? "flex" : "hidden group-hover:flex")
shouldAnimate ? "flex w-[140px] justify-end" : ((isHighlighted || isSelected || alwaysShowHoverDetails) ? "flex" : "hidden group-hover:flex")
)}>
{shouldAnimate ? (
<TextLoop interval={2.1} transition={{ duration: 0.25 }} trigger={shouldAnimate}>
@@ -13,6 +13,8 @@ import { RiLoader4Line, RiSearchLine, RiTimeLine, RiGitBranchLine, RiArrowGoBack
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import type { Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { useDeviceInfo } from '@/lib/device';
import { cn } from '@/lib/utils';
interface TimelineDialogProps {
open: boolean;
@@ -34,6 +36,8 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
const messages = useSessionMessageRecords(currentSessionId ?? '');
const revertToMessage = useSessionUIStore((state) => state.revertToMessage);
const forkFromMessage = useSessionUIStore((state) => state.forkFromMessage);
const { isMobile, isTablet } = useDeviceInfo();
const alwaysShowActions = isMobile || isTablet;
const [forkingMessageId, setForkingMessageId] = React.useState<string | null>(null);
const [searchQuery, setSearchQuery] = React.useState('');
@@ -140,11 +144,11 @@ export const TimelineDialog: React.FC<TimelineDialogProps> = ({
</p>
<div className="flex-shrink-0 h-5 flex items-center mr-2">
<span className="typography-meta text-muted-foreground whitespace-nowrap group-hover:hidden">
<span className={cn("typography-meta text-muted-foreground whitespace-nowrap", alwaysShowActions ? "hidden" : "group-hover:hidden")}>
{relativeTime}
</span>
<div className="hidden group-hover:flex gap-1">
<div className={cn("gap-1", alwaysShowActions ? "flex" : "hidden group-hover:flex")}>
<Tooltip>
<TooltipTrigger asChild>
<button
@@ -282,6 +282,7 @@ interface MessageBodyProps {
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
alwaysShowActions?: boolean;
hasTouchInput?: boolean;
copiedCode: string | null;
onCopyCode: (code: string) => void;
@@ -326,10 +327,11 @@ const writeRevealedToolIds = (messageId: string, value: Set<string>): void => {
revealedToolIdsByMessage.set(messageId, new Set(value));
};
const UserMessageBody = React.memo(({ messageId, parts, isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
const UserMessageBody = React.memo(({ messageId, parts, isMobile, alwaysShowActions = isMobile, hasTouchInput, hasTextContent, onCopyMessage, copiedMessage, onShowPopup, agentMention, onRevert, onFork, userActionsMode = 'inline', stickyUserHeaderEnabled = true }: {
messageId: string;
parts: Part[];
isMobile: boolean;
alwaysShowActions?: boolean;
hasTouchInput?: boolean;
hasTextContent?: boolean;
onCopyMessage?: () => void;
@@ -438,7 +440,7 @@ const UserMessageBody = React.memo(({ messageId, parts, isMobile, hasTouchInput,
: userActionsMode === 'inline'
? 'translate-x-5'
: 'translate-x-0',
isMobile
alwaysShowActions
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/message:pointer-events-auto group-hover/message:opacity-100 group-hover/user-actions:pointer-events-auto group-hover/user-actions:opacity-100 group-hover/user-shell:pointer-events-auto group-hover/user-shell:opacity-100'
)}
@@ -838,6 +840,7 @@ const AssistantMessageBody = React.memo(({
syntaxTheme,
isMobile,
alwaysShowActions,
hasTouchInput,
expandedTools,
onToggleTool,
@@ -865,6 +868,7 @@ const AssistantMessageBody = React.memo(({
}, []);
const isTouchContext = Boolean(hasTouchInput ?? isMobile);
const alwaysShowMessageActions = Boolean(alwaysShowActions ?? isMobile);
const awaitingMessageCompletion = !isMessageCompleted;
const animateActivityRows = awaitingMessageCompletion || Boolean(turnGroupingContext?.isWorking);
@@ -1550,6 +1554,7 @@ const AssistantMessageBody = React.memo(({
part={part}
messageId={messageId}
onContentChange={onContentChange}
alwaysShowActions={alwaysShowMessageActions}
/>
);
} else {
@@ -1601,6 +1606,7 @@ const AssistantMessageBody = React.memo(({
onToggle={onToggleTool}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
alwaysShowActions={alwaysShowMessageActions}
onContentChange={onContentChange}
onShowPopup={onShowPopup}
animateTailText={animatedToolIdsLookup.has(toolPart.id)}
@@ -1890,6 +1896,7 @@ const MessageBody = React.memo(({ isUser, ...props }: MessageBodyProps) => {
messageId={props.messageId}
parts={props.parts}
isMobile={props.isMobile}
alwaysShowActions={props.alwaysShowActions}
hasTouchInput={props.hasTouchInput}
hasTextContent={props.hasTextContent}
onCopyMessage={props.onCopyMessage}
@@ -82,6 +82,7 @@ type ReasoningTimelineBlockProps = {
showDuration?: boolean;
isStreaming?: boolean;
actions?: React.ReactNode;
alwaysShowActions?: boolean;
};
export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
@@ -93,6 +94,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
showDuration = true,
isStreaming = false,
actions,
alwaysShowActions = false,
}) => {
const [isExpanded, setIsExpanded] = React.useState(false);
@@ -126,7 +128,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
!isExpanded && (alwaysShowActions ? 'opacity-0' : 'group-hover/tool:opacity-0')
)}
>
<Icon className="h-3.5 w-3.5" />
@@ -135,7 +137,7 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
!isExpanded && (alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/tool:opacity-100')
)}
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
@@ -200,12 +202,14 @@ type ReasoningPartProps = {
part: Part;
onContentChange?: (reason?: ContentChangeReason) => void;
messageId: string;
alwaysShowActions?: boolean;
};
const ReasoningPart = React.memo(({
part,
onContentChange,
messageId,
alwaysShowActions = false,
}: ReasoningPartProps) => {
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
const partWithText = part as PartWithText;
@@ -234,6 +238,7 @@ const ReasoningPart = React.memo(({
time={time}
showDuration={chatRenderMode !== 'sorted'}
isStreaming={isStreaming}
alwaysShowActions={alwaysShowActions}
/>
);
});
@@ -51,6 +51,7 @@ interface ToolPartProps {
onToggle: (toolId: string) => void;
syntaxTheme: { [key: string]: React.CSSProperties };
isMobile: boolean;
alwaysShowActions?: boolean;
onContentChange?: (reason?: ContentChangeReason) => void;
onShowPopup?: (content: ToolPopupContent) => void;
animateTailText?: boolean;
@@ -1804,6 +1805,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
onToggle,
syntaxTheme,
isMobile,
alwaysShowActions = isMobile,
onContentChange,
onShowPopup,
animateTailText = true,
@@ -2522,7 +2524,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
className={cn(
'absolute inset-0 transition-opacity',
isExpanded && 'opacity-0',
!isExpanded && 'group-hover/tool:opacity-0'
!isExpanded && (alwaysShowActions ? 'opacity-0' : 'group-hover/tool:opacity-0')
)}
style={iconStyle}
>
@@ -2533,7 +2535,7 @@ const ToolPart: React.FC<ToolPartProps> = ({
className={cn(
'absolute inset-0 transition-opacity flex items-center justify-center',
isExpanded && 'opacity-100',
!isExpanded && 'opacity-0 group-hover/tool:opacity-100'
!isExpanded && (alwaysShowActions ? 'opacity-100' : 'opacity-0 group-hover/tool:opacity-100')
)}
>
{isExpanded ? <RiArrowDownSLine className="h-3.5 w-3.5" /> : <RiArrowRightSLine className="h-3.5 w-3.5" />}
@@ -2661,6 +2663,7 @@ export default React.memo(ToolPart, (prev, next) => {
&& prev.isExpanded === next.isExpanded
&& prev.syntaxTheme === next.syntaxTheme
&& prev.isMobile === next.isMobile
&& prev.alwaysShowActions === next.alwaysShowActions
&& prev.onContentChange === next.onContentChange
&& prev.onShowPopup === next.onShowPopup
&& prev.animateTailText === next.animateTailText;