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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user