diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index 0a25eb42..a50ad22c 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -535,7 +535,9 @@ export const ChatContainer: React.FC = ({ autoOpenDraft = tr const chatSurfaceMode = useChatSurfaceMode(); const draftOpen = Boolean(newSessionDraft?.open); const initError = useGlobalSyncStore((s) => s.error); - const isDesktopExpandedInput = isExpandedInput && !isMobile; + // Despite the historical name, this now covers mobile too: the mobile + // composer enters the same fullscreen-input mode via its drag handle. + const isDesktopExpandedInput = isExpandedInput; const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat'; const messageListRef = React.useRef(null); const draftProjectLabel = React.useMemo(() => { diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 3d32a657..688976b4 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -53,6 +53,8 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Input } from '@/components/ui/input'; +import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; import { useThemeSystem } from '@/contexts/useThemeSystem'; import { GitHubIssuePickerDialog } from '@/components/session/GitHubIssuePickerDialog'; import { GitHubPrPickerDialog } from '@/components/session/GitHubPrPickerDialog'; @@ -528,6 +530,9 @@ type ComposerAttachmentControlsProps = { openIssuePicker: () => void; openPrPicker: () => void; onOpenSettings?: () => void; + onMenuOpenChange?: (open: boolean) => void; + /** Mobile: open the attachment bottom sheet instead of the dropdown menu. */ + onOpenMobileSheet?: () => void; }; const ComposerAttachmentControls = React.memo(function ComposerAttachmentControls(props: ComposerAttachmentControlsProps) { @@ -556,7 +561,17 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl />
- {isVSCode ? ( + {props.onOpenMobileSheet ? ( + + ) : isVSCode ? ( ) : ( - + + ))} +
+ + + setMobileDraftPicker(null)} + > +
+ setMobileDraftPickerQuery(event.target.value)} + placeholder={t('chat.chatInput.draftPicker.searchBranches')} + className="h-9" + /> +
+ {(() => { + const query = mobileDraftPickerQuery.trim().toLowerCase(); + const matches = (label: string) => !query || label.toLowerCase().includes(query); + const selectedValue = selectedDraftDirectory + ?? draftBranchItems[0]?.value + ?? normalizePath(selectedDraftProject.path) + ?? ''; + const renderRow = (value: string, label: React.ReactNode, key?: string) => ( + + ); + return ( + <> + {projectRootBranchOption && matches(projectRootBranchOption.label) ? ( + <> +
+ {t('chat.chatInput.projectRoot')} +
+ {renderRow(projectRootBranchOption.value, projectRootBranchOption.label)} + + ) : null} +
+ {t('chat.chatInput.worktrees')} + +
+ {worktreeBranchOptions + .filter((option) => matches(option.label)) + .map((option) => renderRow(option.value, `${option.pending ? '⏳ ' : ''}${option.label}`))} + {selectedDraftDirectory && !selectedDraftBranchIsKnown && matches(selectedDraftBranchLabel ?? '') + ? renderRow(selectedDraftDirectory, selectedDraftBranchLabel, 'unknown-current') + : null} + + ); + })()} +
+
+
+ + ) : null} ); }; diff --git a/packages/ui/src/components/chat/MobileAgentButton.tsx b/packages/ui/src/components/chat/MobileAgentButton.tsx index 3a540745..347a18a9 100644 --- a/packages/ui/src/components/chat/MobileAgentButton.tsx +++ b/packages/ui/src/components/chat/MobileAgentButton.tsx @@ -31,7 +31,12 @@ export const MobileAgentButton: React.FC = ({ onCycleAge const longPressTimerRef = React.useRef | null>(null); const isLongPressRef = React.useRef(false); - const handlePointerDown = () => { + 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; @@ -72,6 +77,7 @@ export const MobileAgentButton: React.FC = ({ onCycleAge 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', diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx index 7f3fd913..6b791168 100644 --- a/packages/ui/src/components/dictation/ComposerDictation.tsx +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -31,6 +31,14 @@ interface ComposerDictationProps { disabled?: boolean; onInsert: (text: string) => void; onInsertAndSend: (text: string) => void; + /** Reports whether dictation is active (recording/transcribing/failed overlay shown). */ + onActiveChange?: (active: boolean) => void; + /** Render the mic trigger button (default). Pass false when the host renders + its own trigger and only needs the overlay + recording engine. */ + renderTrigger?: boolean; + /** Rendered at the very top of the active overlay (e.g. the mobile composer + drag handle, so swipe-expand keeps working in Listening mode). */ + topAccessory?: React.ReactNode; } const formatDuration = (seconds: number): string => { @@ -114,6 +122,9 @@ export const ComposerDictation: React.FC = ({ disabled, onInsert, onInsertAndSend, + onActiveChange, + renderTrigger = true, + topAccessory, }) => { const { t } = useI18n(); const { currentTheme } = useThemeSystem(); @@ -167,6 +178,14 @@ export const ComposerDictation: React.FC = ({ statusRef.current = status; }, [status]); + // Layout effect on purpose: the host may expand/collapse the composer in + // response, and that state change must land in the same paint as the + // overlay (a plain effect painted one clipped frame of overlay content + // inside the still-collapsed pill before the morph started). + React.useLayoutEffect(() => { + onActiveChange?.(status !== 'idle'); + }, [status, onActiveChange]); + // Keyboard shortcut (toggle_dictation): idle -> start recording, // recording -> confirm and insert. Dispatched by useKeyboardShortcuts. React.useEffect(() => { @@ -259,27 +278,49 @@ export const ComposerDictation: React.FC = ({ return t('chat.dictation.listening'); })(); + // Dictation must not dismiss the soft keyboard: block the focus transfer + // iOS performs on tap for the mic and every overlay control (same pattern + // as PermissionAutoAcceptButton). + const keepKeyboardFocusProps = { + onMouseDown: (event: React.MouseEvent) => event.preventDefault(), + onPointerDownCapture: (event: React.PointerEvent) => { + if (event.pointerType === 'touch') { + event.preventDefault(); + } + }, + } as const; + return ( <> - + {renderTrigger ? ( + + ) : null} {isActive ? (
= ({ role="dialog" aria-label={t('chat.dictation.overlayAria')} > + {topAccessory}
= ({ <>