Mobile composer redesign: when the keyboard is closed the input collapses into a narrow pill (sessions, attach, placeholder, mic) with a round new-session button that fades away on the draft screen. Model and agent selectors move into a row above the textarea; the draft project/branch pickers and the attachment menu become searchable bottom sheets reusing MobileOverlayPanel; a drag handle (also available while dictating) swipes the composer into and out of a fullscreen mode. Keyboard-lifecycle hardening: composer controls (agent cycle, dictation and its overlay controls) no longer steal focus and dismiss the keyboard; overlays reopen the keyboard on close via a debounced restore chain that survives menu-to-picker handoffs and skips the native file picker; open overlays and dictation keep the composer expanded. Dictation starts directly from the pill and its overlay content fades in after the shape settles. The keyboard slide compensates the pill-to-full height change in one motion, and the mobile highlight mirror is disabled so the caret always matches the text layout.
103 lines
3.7 KiB
TypeScript
103 lines
3.7 KiB
TypeScript
import React from 'react';
|
|
import { cn } from '@/lib/utils';
|
|
import { useConfigStore } from '@/stores/useConfigStore';
|
|
import { useSessionUIStore } from '@/sync/session-ui-store';
|
|
import { useSelectionStore } from '@/sync/selection-store';
|
|
import { getAgentDisplayName } from './mobileControlsUtils';
|
|
import { getAgentColor } from '@/lib/agentColors';
|
|
|
|
interface MobileAgentButtonProps {
|
|
onCycleAgent: () => void;
|
|
onOpenAgentPanel: () => void;
|
|
className?: string;
|
|
}
|
|
|
|
const LONG_PRESS_MS = 500;
|
|
|
|
// NOTE: Use pointer events instead of onClick to keep soft keyboard open on mobile
|
|
export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAgent, onOpenAgentPanel, className }) => {
|
|
const currentAgentName = useConfigStore((state) => state.currentAgentName);
|
|
const getVisibleAgents = useConfigStore((state) => state.getVisibleAgents);
|
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
|
const sessionAgentName = useSelectionStore((state) =>
|
|
currentSessionId ? state.getSessionAgentSelection(currentSessionId) : null
|
|
);
|
|
|
|
const agents = getVisibleAgents();
|
|
const uiAgentName = currentSessionId ? (sessionAgentName || currentAgentName) : currentAgentName;
|
|
const agentLabel = getAgentDisplayName(agents, uiAgentName);
|
|
const agentColor = getAgentColor(uiAgentName);
|
|
|
|
const longPressTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const isLongPressRef = React.useRef(false);
|
|
|
|
const handlePointerDown = (event: React.PointerEvent) => {
|
|
// Same pattern as PermissionAutoAcceptButton: block the focus transfer
|
|
// iOS performs on touch so cycling the agent keeps the keyboard open.
|
|
if (event.pointerType === 'touch') {
|
|
event.preventDefault();
|
|
}
|
|
isLongPressRef.current = false;
|
|
longPressTimerRef.current = setTimeout(() => {
|
|
isLongPressRef.current = true;
|
|
onOpenAgentPanel();
|
|
}, LONG_PRESS_MS);
|
|
};
|
|
|
|
// Use onPointerUp (not onClick) to prevent focus transfer that closes mobile keyboard
|
|
const handlePointerUp = () => {
|
|
if (longPressTimerRef.current) {
|
|
clearTimeout(longPressTimerRef.current);
|
|
longPressTimerRef.current = null;
|
|
}
|
|
if (!isLongPressRef.current) {
|
|
onCycleAgent();
|
|
}
|
|
};
|
|
|
|
const handlePointerLeave = () => {
|
|
if (longPressTimerRef.current) {
|
|
clearTimeout(longPressTimerRef.current);
|
|
longPressTimerRef.current = null;
|
|
}
|
|
};
|
|
|
|
React.useEffect(() => {
|
|
return () => {
|
|
if (longPressTimerRef.current) {
|
|
clearTimeout(longPressTimerRef.current);
|
|
}
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onPointerDown={handlePointerDown}
|
|
onPointerUp={handlePointerUp} // Don't use onClick - it closes mobile keyboard
|
|
onPointerLeave={handlePointerLeave}
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
className={cn(
|
|
'inline-flex min-w-0 items-stretch select-none',
|
|
'rounded-lg',
|
|
'typography-micro font-medium',
|
|
'focus:outline-none hover:bg-[var(--interactive-hover)]',
|
|
'touch-none',
|
|
className
|
|
)}
|
|
style={{
|
|
height: '26px',
|
|
maxHeight: '26px',
|
|
minHeight: '26px',
|
|
color: `var(${agentColor.var})`,
|
|
}}
|
|
title={agentLabel}
|
|
>
|
|
<span className="flex h-full w-full min-w-0 items-center">
|
|
<span className="truncate">{agentLabel}</span>
|
|
</span>
|
|
</button>
|
|
);
|
|
};
|