feat(mobile): collapsed pill composer and mobile input redesign
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.
This commit is contained in:
@@ -535,7 +535,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ 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<MessageListHandle | null>(null);
|
||||
const draftProjectLabel = React.useMemo(() => {
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
|
||||
<div className="relative inline-flex">
|
||||
{isVSCode ? (
|
||||
{props.onOpenMobileSheet ? (
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={props.onOpenMobileSheet}
|
||||
title={t('chat.chatInput.actions.addAttachment')}
|
||||
aria-label={t('chat.chatInput.actions.addAttachment')}
|
||||
>
|
||||
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
) : isVSCode ? (
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
@@ -567,7 +582,7 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
|
||||
<Icon name="attachment-2" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
) : (
|
||||
<DropdownMenu>
|
||||
<DropdownMenu onOpenChange={props.onMenuOpenChange}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
@@ -626,6 +641,8 @@ const ComposerAttachmentControls = React.memo(function ComposerAttachmentControl
|
||||
&& prev.footerIconButtonClass === next.footerIconButtonClass
|
||||
&& prev.iconSizeClass === next.iconSizeClass
|
||||
&& prev.onOpenSettings === next.onOpenSettings
|
||||
&& prev.onMenuOpenChange === next.onMenuOpenChange
|
||||
&& prev.onOpenMobileSheet === next.onOpenMobileSheet
|
||||
));
|
||||
|
||||
type PermissionAutoAcceptButtonProps = {
|
||||
@@ -989,6 +1006,29 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const [snippetQuery, setSnippetQuery] = React.useState('');
|
||||
const [textareaSize, setTextareaSize] = React.useState<{ height: number; maxHeight: number } | null>(null);
|
||||
const [mobileControlsPanel, setMobileControlsPanel] = React.useState<MobileControlsPanel>(null);
|
||||
// Mobile pill composer: when the keyboard is closed the composer collapses
|
||||
// into a narrow pill (+ / placeholder / mic) with a round new-session button
|
||||
// beside it. Any interaction expands back into the full composer.
|
||||
const [mobileComposerExpanded, setMobileComposerExpanded] = React.useState(false);
|
||||
const [mobileTextareaFocused, setMobileTextareaFocused] = React.useState(false);
|
||||
const [mobileDictationActive, setMobileDictationActive] = React.useState(false);
|
||||
const [mobileAttachMenuOpen, setMobileAttachMenuOpen] = React.useState(false);
|
||||
const [mobileDraftPicker, setMobileDraftPicker] = React.useState<'project' | 'branch' | null>(null);
|
||||
const [mobileDraftPickerQuery, setMobileDraftPickerQuery] = React.useState('');
|
||||
// True while ANY MobileOverlayPanel is open (sessions sheet, model/agent
|
||||
// panels, pickers...). Opening one closes the keyboard, which must not
|
||||
// collapse the composer into the pill under the overlay.
|
||||
const [mobileOverlayHostBusy, setMobileOverlayHostBusy] = React.useState(false);
|
||||
// Set while an expansion is settling (focus/dictation not yet active) so the
|
||||
// collapse watcher doesn't immediately fold the composer back into the pill.
|
||||
const mobileExpandIntentRef = React.useRef<'focus' | null>(null);
|
||||
// Keyboard restore across overlays: opening an overlay closes the keyboard;
|
||||
// if it was open at that moment, reopen it when the overlay closes.
|
||||
const lastMobileBlurAtRef = React.useRef(0);
|
||||
const restoreKeyboardAfterOverlayRef = React.useRef(false);
|
||||
// Pill ↔ full composer morph: the wrapper FLIP-animates its height between
|
||||
// the two shapes while the swapped content fades in.
|
||||
const composerHandleTouchRef = React.useRef<{ startY: number; fired: boolean } | null>(null);
|
||||
// Message history navigation state (up/down arrow to recall previous messages)
|
||||
const [historyIndex, setHistoryIndex] = React.useState(-1); // -1 = not browsing, 0+ = index from most recent
|
||||
const [draftMessage, setDraftMessage] = React.useState(''); // Preserves input when entering history mode
|
||||
@@ -1029,6 +1069,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const newSessionDraftOpen = Boolean(newSessionDraft?.open);
|
||||
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const availableWorktreesByProject = useSessionUIStore((s) => s.availableWorktreesByProject);
|
||||
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
|
||||
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
|
||||
@@ -1146,6 +1187,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}, [currentSessionId, currentDirectory, t]);
|
||||
|
||||
const isDesktopExpanded = isExpandedInput && !isMobile;
|
||||
// Mobile fullscreen composer (entered via the drag handle's swipe-up).
|
||||
const isMobileExpanded = isExpandedInput && isMobile;
|
||||
const isComposerExpanded = isDesktopExpanded || isMobileExpanded;
|
||||
// Rounder composer on mobile (touch UI reads better with a softer corner).
|
||||
const chatInputRadius = isMobile ? '1.5rem' : 'var(--radius-xl)';
|
||||
const useCompactChatPlaceholder = isMobile || isNarrowComposer;
|
||||
@@ -1650,10 +1694,12 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
// Set the panel state BEFORE blurring: the collapse watcher and the
|
||||
// overlay-host observer must already see the overlay as open when the
|
||||
// keyboard-close lands, otherwise the composer folds into the pill
|
||||
// under the sheet.
|
||||
setMobileControlsPanel(panel);
|
||||
textareaRef.current?.blur();
|
||||
requestAnimationFrame(() => {
|
||||
setMobileControlsPanel(panel);
|
||||
});
|
||||
}, [isMobile]);
|
||||
|
||||
// Consume pending input text (e.g., from revert action)
|
||||
@@ -2760,7 +2806,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
|
||||
const previousScrollTop = textarea.scrollTop;
|
||||
|
||||
if (isDesktopExpanded) {
|
||||
if (isComposerExpanded) {
|
||||
textarea.style.height = '100%';
|
||||
textarea.style.maxHeight = 'none';
|
||||
setTextareaSize(null);
|
||||
@@ -2801,7 +2847,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
return { height: nextHeight, maxHeight };
|
||||
});
|
||||
}, [isDesktopExpanded]);
|
||||
}, [isComposerExpanded]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const allowShrink = message.length < previousMessageLengthRef.current;
|
||||
@@ -3983,6 +4029,189 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
});
|
||||
}, [draftBranchItems, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, newSessionDraft?.preserveDirectoryOverride, selectedDraftDirectory, selectedDraftProject, setNewSessionDraftTarget, showDraftTargetSelectors]);
|
||||
|
||||
// ── Mobile pill composer state machine ─────────────────────────────────
|
||||
const expandMobileComposer = React.useCallback((intent: 'focus') => {
|
||||
mobileExpandIntentRef.current = intent;
|
||||
setMobileComposerExpanded(true);
|
||||
// Wait a frame so the full composer's textarea mounts.
|
||||
// No re-pin here: the keyboard choreography measures the pending
|
||||
// bottom distance (which includes the pill→full height change) and
|
||||
// folds it into the single keyboard slide.
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMobileNewSession = React.useCallback(() => {
|
||||
if (newSessionDraftOpen) return;
|
||||
openNewSessionDraft(currentDirectory ? { directoryOverride: currentDirectory } : undefined);
|
||||
}, [newSessionDraftOpen, openNewSessionDraft, currentDirectory]);
|
||||
|
||||
const openMobileAttachSheet = React.useCallback(() => {
|
||||
setMobileAttachMenuOpen(true);
|
||||
}, []);
|
||||
|
||||
const mobileComposerExpandedRef = React.useRef(mobileComposerExpanded);
|
||||
React.useEffect(() => {
|
||||
mobileComposerExpandedRef.current = mobileComposerExpanded;
|
||||
});
|
||||
|
||||
const handleMobileDictationActiveChange = React.useCallback((active: boolean) => {
|
||||
setMobileDictationActive(active);
|
||||
if (active) {
|
||||
mobileExpandIntentRef.current = null;
|
||||
// Dictation engine went live (possibly started from the pill):
|
||||
// switch straight into the voice variant of the full composer.
|
||||
if (!mobileComposerExpandedRef.current) {
|
||||
setMobileComposerExpanded(true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Dictation ended. The insert flow hands focus back to the textarea a
|
||||
// tick later — if that happened, stay expanded; otherwise (cancel,
|
||||
// discard, insert-and-send) collapse straight back to the pill without
|
||||
// parking on the normal composer for the usual grace period.
|
||||
window.setTimeout(() => {
|
||||
if (!mobileComposerExpandedRef.current) return;
|
||||
if (document.activeElement === textareaRef.current) return;
|
||||
setMobileComposerExpanded(false);
|
||||
setExpandedInput(false);
|
||||
}, 30);
|
||||
}, [setExpandedInput]);
|
||||
|
||||
// Watch the shared overlay portal root: any mounted MobileOverlayPanel
|
||||
// (sessions sheet, model/agent panels, draft pickers, ...) counts as busy.
|
||||
// Observing the host catches overlays whose open-state lives in other
|
||||
// components without threading their state here.
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || typeof document === 'undefined') return;
|
||||
let host = document.getElementById('mobile-overlay-root');
|
||||
if (!host) {
|
||||
// Same lazy-create contract as MobileOverlayPanel's ensureOverlayRoot.
|
||||
host = document.createElement('div');
|
||||
host.id = 'mobile-overlay-root';
|
||||
document.body.appendChild(host);
|
||||
}
|
||||
const hostEl = host;
|
||||
const update = () => setMobileOverlayHostBusy(hostEl.childElementCount > 0);
|
||||
update();
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(hostEl, { childList: true });
|
||||
return () => observer.disconnect();
|
||||
}, [isMobile]);
|
||||
|
||||
// If the keyboard was open (or closed just moments ago by the overlay's own
|
||||
// blur) when an overlay appeared, bring it back once every overlay is gone.
|
||||
// The attach dropdown and the GitHub issue/PR pickers join the same chain,
|
||||
// so menu → picker → close restores the keyboard at the end of the flow.
|
||||
const mobileOverlayOpen = mobileOverlayHostBusy
|
||||
|| Boolean(mobileControlsPanel)
|
||||
|| mobileAttachMenuOpen
|
||||
|| issuePickerOpen
|
||||
|| prPickerOpen;
|
||||
React.useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
if (mobileOverlayOpen) {
|
||||
if (mobileTextareaFocused || Date.now() - lastMobileBlurAtRef.current < 800) {
|
||||
restoreKeyboardAfterOverlayRef.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!restoreKeyboardAfterOverlayRef.current) return;
|
||||
// Debounced: overlay chains hand off with a frame of "nothing open"
|
||||
// between steps (attach sheet closes → issue/PR picker opens a frame
|
||||
// later). Restoring instantly in that gap would pop the keyboard open
|
||||
// inside the next overlay — wait out the gap and cancel if another
|
||||
// overlay appears.
|
||||
const timer = window.setTimeout(() => {
|
||||
restoreKeyboardAfterOverlayRef.current = false;
|
||||
textareaRef.current?.focus({ preventScroll: true });
|
||||
}, 180);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isMobile, mobileOverlayOpen, mobileTextareaFocused]);
|
||||
|
||||
// Fold the full composer back into the pill once nothing keeps it open:
|
||||
// keyboard closed (textarea blurred), no dictation, no sheet/menu/dialog.
|
||||
// The short delay bridges focus moving between composer controls.
|
||||
const mobileComposerBusy = mobileTextareaFocused
|
||||
|| mobileOverlayHostBusy
|
||||
|| mobileDictationActive
|
||||
|| Boolean(mobileControlsPanel)
|
||||
|| mobileAttachMenuOpen
|
||||
|| mobileDraftPicker !== null
|
||||
|| issuePickerOpen
|
||||
|| prPickerOpen
|
||||
|| isDragging;
|
||||
React.useEffect(() => {
|
||||
if (!isMobile || !mobileComposerExpanded || mobileComposerBusy) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
mobileExpandIntentRef.current = null;
|
||||
setMobileComposerExpanded(false);
|
||||
setExpandedInput(false);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isMobile, mobileComposerExpanded, mobileComposerBusy, setExpandedInput]);
|
||||
|
||||
// Reset the picker search whenever a draft picker sheet opens/closes.
|
||||
React.useEffect(() => {
|
||||
setMobileDraftPickerQuery('');
|
||||
}, [mobileDraftPicker]);
|
||||
|
||||
// ── Composer drag handle (mobile): swipe up = fullscreen, swipe down =
|
||||
// leave fullscreen or dismiss the keyboard. ────────────────────────────
|
||||
const handleComposerHandleTouchStart = React.useCallback((event: React.TouchEvent) => {
|
||||
const touch = event.touches.item(0);
|
||||
composerHandleTouchRef.current = touch ? { startY: touch.clientY, fired: false } : null;
|
||||
}, []);
|
||||
const handleComposerHandleTouchMove = React.useCallback((event: React.TouchEvent) => {
|
||||
const state = composerHandleTouchRef.current;
|
||||
if (!state || state.fired) return;
|
||||
const touch = event.touches.item(0);
|
||||
if (!touch) return;
|
||||
const dy = touch.clientY - state.startY;
|
||||
if (dy <= -28) {
|
||||
state.fired = true;
|
||||
if (!isExpandedInput) setExpandedInput(true);
|
||||
} else if (dy >= 28) {
|
||||
state.fired = true;
|
||||
if (isExpandedInput) {
|
||||
setExpandedInput(false);
|
||||
} else {
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
}
|
||||
}, [isExpandedInput, setExpandedInput]);
|
||||
const handleComposerHandleTouchEnd = React.useCallback(() => {
|
||||
composerHandleTouchRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Shared drag handle: rendered at the top of the full composer AND inside
|
||||
// the dictation overlay, so swipe-expand/collapse works in Listening mode.
|
||||
// Memoized so the always-mounted dictation instance's memo stays effective.
|
||||
const mobileComposerHandle = React.useMemo(() => isMobile ? (
|
||||
<div
|
||||
// Generous hit area (~28px tall, full width); the visible bar stays
|
||||
// slim inside it.
|
||||
className="relative z-10 flex touch-none items-center justify-center py-2"
|
||||
onTouchStart={handleComposerHandleTouchStart}
|
||||
onTouchMove={handleComposerHandleTouchMove}
|
||||
onTouchEnd={handleComposerHandleTouchEnd}
|
||||
onTouchCancel={handleComposerHandleTouchEnd}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
className="h-1.5 w-12 rounded-full"
|
||||
style={{ backgroundColor: currentTheme.colors.interactive.border }}
|
||||
/>
|
||||
</div>
|
||||
) : null, [
|
||||
isMobile,
|
||||
handleComposerHandleTouchStart,
|
||||
handleComposerHandleTouchMove,
|
||||
handleComposerHandleTouchEnd,
|
||||
currentTheme.colors.interactive.border,
|
||||
]);
|
||||
|
||||
const footerPaddingClass = isMobile ? 'px-1.5 py-1.5' : (isVSCode ? 'px-1.5 py-1' : 'px-2.5 py-1.5');
|
||||
const buttonSizeClass = isMobile ? 'h-8 w-8' : (isVSCode ? 'h-5 w-5' : 'h-6 w-6');
|
||||
const sendIconSizeClass = isMobile ? 'h-4 w-4' : (isVSCode ? 'h-3.5 w-3.5' : 'h-4 w-4');
|
||||
@@ -4044,6 +4273,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
className={cn(
|
||||
"relative w-full pt-0 pb-4",
|
||||
isDesktopExpanded && 'flex h-full min-h-0 flex-col pt-4',
|
||||
isMobileExpanded && 'flex h-full min-h-0 flex-col pt-2',
|
||||
isMobile && 'bottom-safe-area oc-mobile-composer'
|
||||
)}
|
||||
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
|
||||
@@ -4060,7 +4290,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</h1>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('chat-input-column relative overflow-visible', isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
<div className={cn('chat-input-column relative overflow-visible', isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
<AttachedFilesList onShowPopup={handleShowAttachmentPreview} />
|
||||
<QueuedMessageChips
|
||||
onEditMessage={handleQueuedMessageEdit}
|
||||
@@ -4252,7 +4482,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
showTodos
|
||||
leftAccessory={newSessionDraftOpen || !hasPendingChanges ? null : <PendingChangesBar />}
|
||||
/>
|
||||
{showDraftTargetSelectors && selectedDraftProject ? (
|
||||
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-1.5 px-0.5">
|
||||
<Select
|
||||
value={selectedDraftProject.id}
|
||||
@@ -4326,10 +4556,118 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<div className="mb-1.5 flex min-w-0 items-center gap-x-2 px-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => setMobileDraftPicker('project')}
|
||||
>
|
||||
{renderProjectLabelWithIcon(selectedDraftProject)}
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
{shouldShowDraftBranchSelector ? (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-7 min-w-0 max-w-[48vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => setMobileDraftPicker('branch')}
|
||||
>
|
||||
<span className="truncate">{selectedDraftBranchLabel ?? t('chat.chatInput.branch')}</span>
|
||||
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
// Desktop: layout-transparent. Mobile: positioning host for
|
||||
// the wrapper-level dictation overlay across pill/full states.
|
||||
className={cn(
|
||||
!isMobile && 'contents',
|
||||
isMobile && 'relative',
|
||||
isMobileExpanded && 'flex min-h-0 flex-1 flex-col',
|
||||
)}
|
||||
>
|
||||
{isMobile && !mobileComposerExpanded ? (
|
||||
<div className="oc-composer-morph-fade flex items-center gap-2">
|
||||
<div
|
||||
className="flex h-11 min-w-0 flex-1 items-center gap-x-0.5 rounded-full border border-border/80 pl-2 pr-1"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
>
|
||||
<MobileSessionPanelTrigger
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
/>
|
||||
<ComposerAttachmentControls
|
||||
isVSCode={isVSCode}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
fileInputRef={fileInputRef}
|
||||
handleLocalFileSelect={handleLocalFileSelect}
|
||||
handlePickLocalFiles={handlePickLocalFiles}
|
||||
openIssuePicker={openIssuePicker}
|
||||
openPrPicker={openPrPicker}
|
||||
onOpenMobileSheet={openMobileAttachSheet}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-full min-w-0 flex-1 cursor-text items-center px-1.5 text-left"
|
||||
onClick={() => expandMobileComposer('focus')}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'truncate typography-ui-label',
|
||||
message.trim() ? 'text-foreground' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{message.trim()
|
||||
? message
|
||||
: currentSessionId || newSessionDraftOpen
|
||||
? t('chat.chatInput.placeholder.chatCompact')
|
||||
: t('chat.chatInput.placeholder.selectSession')}
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => {
|
||||
// Start recording in place; the composer morphs
|
||||
// into the voice variant once dictation is live
|
||||
// (handleMobileDictationActiveChange).
|
||||
window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle'));
|
||||
}}
|
||||
title={t('chat.dictation.start')}
|
||||
aria-label={t('chat.dictation.start')}
|
||||
>
|
||||
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
</div>
|
||||
{/* New-session button: fades/shrinks away when the draft is
|
||||
already open, letting the pill expand into its place. */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex-shrink-0 overflow-hidden transition-all duration-200 ease-out',
|
||||
newSessionDraftOpen ? 'w-0 opacity-0' : 'w-11 opacity-100',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-11 w-11 cursor-pointer items-center justify-center rounded-full border border-border/80 text-foreground"
|
||||
style={{ backgroundColor: currentTheme?.colors?.surface?.subtle }}
|
||||
onClick={handleMobileNewSession}
|
||||
disabled={newSessionDraftOpen}
|
||||
title={t('mobile.sessions.newChat')}
|
||||
aria-label={t('mobile.sessions.newChat')}
|
||||
>
|
||||
<Icon name="add" className="h-5 w-5 text-current" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col relative overflow-visible",
|
||||
isDesktopExpanded && 'flex-1 min-h-0',
|
||||
isMobile && 'oc-composer-morph-fade',
|
||||
isComposerExpanded && 'flex-1 min-h-0',
|
||||
"border border-border/80",
|
||||
"focus-within:ring-1",
|
||||
inputMode === 'shell'
|
||||
@@ -4449,20 +4787,35 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
)}
|
||||
{/* Positioning context for the dictation overlay: covers the
|
||||
text area + footer exactly, excluding MobileSessionStatusBar. */}
|
||||
<div className={cn('relative flex flex-col', isDesktopExpanded && 'flex-1 min-h-0')}>
|
||||
<div className={cn("overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
<div className={cn('relative flex flex-col', isComposerExpanded && 'flex-1 min-h-0')}>
|
||||
<div className={cn("overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
{mobileComposerHandle}
|
||||
{isMobile ? (
|
||||
<div className="scrollbar-none relative z-10 flex items-center gap-x-2 overflow-x-auto px-3 pb-0.5 pt-1.5">
|
||||
<MemoMobileModelButton onOpenModel={() => handleOpenMobilePanel('model')} className="flex-shrink-0" />
|
||||
<MemoMobileAgentButton
|
||||
onOpenAgentPanel={handleOpenAgentPanel}
|
||||
onCycleAgent={handleCycleAgent}
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-1 px-3 pt-1 flex-wrap relative z-10">
|
||||
<AttachedVSCodeFileChips onShowPopup={handleShowAttachmentPreview} />
|
||||
<ActiveEditorFileSuggestion />
|
||||
</div>
|
||||
<div className={cn("relative overflow-hidden", isDesktopExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
{highlightedComposerContent && (
|
||||
<div className={cn("relative overflow-hidden", isComposerExpanded && 'flex flex-1 min-h-0 flex-col')}>
|
||||
{/* No highlight mirror on mobile: over wrapped text its
|
||||
layout drifts from the real textarea, which visually
|
||||
misplaces the caret. Plain textarea text keeps caret
|
||||
and text in the same layout. */}
|
||||
{highlightedComposerContent && !isMobile && (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'pointer-events-none absolute inset-0 z-0 whitespace-pre-wrap break-words px-3 rounded-b-none',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
isComposerExpanded
|
||||
? cn('h-full min-h-0', isMobile ? 'py-2.5' : 'py-4')
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
@@ -4508,6 +4861,16 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
cursorPosRef.current = ta.selectionStart ?? 0;
|
||||
updateAutocompleteOverlayPosition();
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (!isMobile) return;
|
||||
mobileExpandIntentRef.current = null;
|
||||
setMobileTextareaFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!isMobile) return;
|
||||
lastMobileBlurAtRef.current = Date.now();
|
||||
setMobileTextareaFocused(false);
|
||||
}}
|
||||
placeholder={currentSessionId || newSessionDraftOpen
|
||||
? inputMode === 'shell'
|
||||
? t('chat.chatInput.placeholder.shell')
|
||||
@@ -4517,22 +4880,22 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
autoCorrect={isMobile ? "on" : "off"}
|
||||
autoCapitalize={isMobile ? "sentences" : "off"}
|
||||
spellCheck={isMobile || inputSpellcheckEnabled}
|
||||
fillContainer={isDesktopExpanded}
|
||||
outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isDesktopExpanded && 'flex-1 min-h-0')}
|
||||
fillContainer={isComposerExpanded}
|
||||
outerClassName={cn('ring-0 bg-transparent shadow-none hover:bg-transparent focus-within:ring-0', isComposerExpanded && 'flex-1 min-h-0')}
|
||||
className={cn(
|
||||
'min-h-[52px] resize-none border-0 px-3 rounded-b-none appearance-none hover:border-transparent bg-transparent relative z-10',
|
||||
isDesktopExpanded
|
||||
? 'h-full min-h-0 py-4'
|
||||
isComposerExpanded
|
||||
? cn('h-full min-h-0', isMobile ? 'py-2.5' : 'py-4')
|
||||
: isMobile
|
||||
? 'py-2.5'
|
||||
: 'pt-4 pb-2',
|
||||
inputMode === 'shell' && 'font-mono',
|
||||
highlightedComposerContent && 'text-transparent caret-[var(--surface-foreground)]',
|
||||
highlightedComposerContent && !isMobile && 'text-transparent caret-[var(--surface-foreground)]',
|
||||
)}
|
||||
style={{
|
||||
flex: isDesktopExpanded ? '1 1 auto' : 'none',
|
||||
height: !isDesktopExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isDesktopExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
flex: isComposerExpanded ? '1 1 auto' : 'none',
|
||||
height: !isComposerExpanded && textareaSize ? `${textareaSize.height}px` : undefined,
|
||||
maxHeight: !isComposerExpanded && textareaSize ? `${textareaSize.maxHeight}px` : undefined,
|
||||
borderTopLeftRadius: chatInputRadius,
|
||||
borderTopRightRadius: chatInputRadius,
|
||||
}}
|
||||
@@ -4570,6 +4933,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
openIssuePicker={openIssuePicker}
|
||||
openPrPicker={openPrPicker}
|
||||
onOpenSettings={onOpenSettings}
|
||||
onOpenMobileSheet={openMobileAttachSheet}
|
||||
/>
|
||||
<PermissionAutoAcceptButton
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
@@ -4580,25 +4944,29 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center min-w-0 gap-x-1 justify-end">
|
||||
<div className="flex items-center gap-x-2 min-w-0 max-w-[60vw] flex-shrink">
|
||||
<MemoMobileModelButton onOpenModel={() => handleOpenMobilePanel('model')} className="min-w-0 flex-shrink" />
|
||||
<MemoMobileAgentButton
|
||||
onOpenAgentPanel={handleOpenAgentPanel}
|
||||
onCycleAgent={handleCycleAgent}
|
||||
className="min-w-0 flex-shrink"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-1 flex-shrink-0">
|
||||
<MemoComposerDictation
|
||||
radius={chatInputRadius}
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
footerPaddingClass={footerPaddingClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
sendIconSizeClass={sendIconSizeClass}
|
||||
onInsert={handleDictationInsert}
|
||||
onInsertAndSend={handleDictationInsertAndSend}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
// Keep the soft keyboard open (same guard as
|
||||
// PermissionAutoAcceptButton); the recording
|
||||
// engine lives in the wrapper-level
|
||||
// ComposerDictation instance.
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onPointerDownCapture={(event) => {
|
||||
if (event.pointerType === 'touch') {
|
||||
event.preventDefault();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
window.dispatchEvent(new CustomEvent('openchamber:dictation-toggle'));
|
||||
}}
|
||||
disabled={mobileDictationActive}
|
||||
title={t('chat.dictation.start')}
|
||||
aria-label={t('chat.dictation.start')}
|
||||
>
|
||||
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
<ComposerActionButtons
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
@@ -4616,11 +4984,6 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MemoModelControls
|
||||
className="hidden"
|
||||
mobilePanel={mobileControlsPanel}
|
||||
onMobilePanelChange={setMobileControlsPanel}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -4683,9 +5046,42 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */}
|
||||
{isMobile && <MobileSessionStatusBar />}
|
||||
</div>
|
||||
)}
|
||||
{/* Wrapper-level dictation engine + overlay: stays mounted across
|
||||
the pill ↔ composer swap so a recording started from the pill
|
||||
survives the morph. Its absolute overlay covers whichever
|
||||
shape the wrapper currently has. */}
|
||||
{isMobile ? (
|
||||
<MemoComposerDictation
|
||||
radius={chatInputRadius}
|
||||
isMobile={isMobile}
|
||||
footerIconButtonClass={footerIconButtonClass}
|
||||
footerPaddingClass={footerPaddingClass}
|
||||
iconSizeClass={iconSizeClass}
|
||||
sendIconSizeClass={sendIconSizeClass}
|
||||
onInsert={handleDictationInsert}
|
||||
onInsertAndSend={handleDictationInsertAndSend}
|
||||
onActiveChange={handleMobileDictationActiveChange}
|
||||
renderTrigger={false}
|
||||
topAccessory={mobileComposerHandle}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{/* Mobile session panel: slide-up overlay toggled by
|
||||
MobileSessionPanelTrigger. Mounted outside the pill
|
||||
conditional so the pill's trigger works too. */}
|
||||
{isMobile && <MobileSessionStatusBar />}
|
||||
{/* Hidden host for the model/agent/variant bottom sheets. Kept
|
||||
outside the pill conditional so an open panel survives (and
|
||||
stays visible over) the collapsed composer. */}
|
||||
{isMobile ? (
|
||||
<MemoModelControls
|
||||
className="hidden"
|
||||
mobilePanel={mobileControlsPanel}
|
||||
onMobilePanelChange={setMobileControlsPanel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
|
||||
<DraftPresetChips onSubmit={submitPresetPrompt} className="chat-input-column mt-4" />
|
||||
@@ -4722,6 +5118,172 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
onOpenChange={handleAttachmentPreviewOpenChange}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{/* Mobile attachment sheet: replaces the dropdown (which stole focus and
|
||||
dismissed the keyboard) and leaves room for more actions later. */}
|
||||
{isMobile ? (
|
||||
<MobileOverlayPanel
|
||||
open={mobileAttachMenuOpen}
|
||||
title={t('chat.chatInput.actions.addAttachment')}
|
||||
onClose={() => setMobileAttachMenuOpen(false)}
|
||||
>
|
||||
<div className="flex flex-col px-3 pb-4 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
// The native file/photo picker takes over next — restoring
|
||||
// the keyboard in between would flash it open and shut.
|
||||
restoreKeyboardAfterOverlayRef.current = false;
|
||||
setMobileAttachMenuOpen(false);
|
||||
requestAnimationFrame(handlePickLocalFiles);
|
||||
}}
|
||||
>
|
||||
<Icon name="attachment-2" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.attachFiles')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
setMobileAttachMenuOpen(false);
|
||||
requestAnimationFrame(openIssuePicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="github" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkGithubIssue')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2 py-3 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
setMobileAttachMenuOpen(false);
|
||||
requestAnimationFrame(openPrPicker);
|
||||
}}
|
||||
>
|
||||
<Icon name="git-pull-request" className="h-[18px] w-[18px] flex-shrink-0 text-muted-foreground" />
|
||||
{t('chat.chatInput.actions.linkGithubPr')}
|
||||
</button>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
) : null}
|
||||
|
||||
{/* Mobile draft target pickers: bottom sheets replacing the inline
|
||||
project/branch Selects (which desktop keeps). */}
|
||||
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
|
||||
<>
|
||||
<MobileOverlayPanel
|
||||
open={mobileDraftPicker === 'project'}
|
||||
title={t('chat.chatInput.draftPicker.projectTitle')}
|
||||
onClose={() => setMobileDraftPicker(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
|
||||
<Input
|
||||
value={mobileDraftPickerQuery}
|
||||
onChange={(event) => setMobileDraftPickerQuery(event.target.value)}
|
||||
placeholder={t('chat.chatInput.draftPicker.searchProjects')}
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{projects
|
||||
.filter((project) => {
|
||||
const query = mobileDraftPickerQuery.trim().toLowerCase();
|
||||
if (!query) return true;
|
||||
return getProjectDisplayLabel(project).toLowerCase().includes(query)
|
||||
|| project.path.toLowerCase().includes(query);
|
||||
})
|
||||
.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
handleDraftProjectChange(project.id);
|
||||
setMobileDraftPicker(null);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1">{renderProjectLabelWithIcon(project)}</span>
|
||||
{project.id === selectedDraftProject.id ? (
|
||||
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
<MobileOverlayPanel
|
||||
open={mobileDraftPicker === 'branch'}
|
||||
title={t('chat.chatInput.branch')}
|
||||
onClose={() => setMobileDraftPicker(null)}
|
||||
>
|
||||
<div className="flex flex-col gap-2 px-3 pb-4 pt-1">
|
||||
<Input
|
||||
value={mobileDraftPickerQuery}
|
||||
onChange={(event) => setMobileDraftPickerQuery(event.target.value)}
|
||||
placeholder={t('chat.chatInput.draftPicker.searchBranches')}
|
||||
className="h-9"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
{(() => {
|
||||
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) => (
|
||||
<button
|
||||
key={key ?? value}
|
||||
type="button"
|
||||
className="flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2.5 text-left typography-ui-label hover:bg-[var(--interactive-hover)]"
|
||||
onClick={() => {
|
||||
handleDraftDirectoryChange(value);
|
||||
setMobileDraftPicker(null);
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{label}</span>
|
||||
{value === selectedValue ? (
|
||||
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{projectRootBranchOption && matches(projectRootBranchOption.label) ? (
|
||||
<>
|
||||
<div className="px-2 pb-1 pt-1.5 text-muted-foreground typography-meta">
|
||||
{t('chat.chatInput.projectRoot')}
|
||||
</div>
|
||||
{renderRow(projectRootBranchOption.value, projectRootBranchOption.label)}
|
||||
</>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between px-2 pb-1 pt-2">
|
||||
<span className="text-muted-foreground typography-meta">{t('chat.chatInput.worktrees')}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-muted-foreground typography-meta hover:text-foreground"
|
||||
onClick={() => {
|
||||
setMobileDraftPicker(null);
|
||||
void createWorktreeDraft();
|
||||
}}
|
||||
>
|
||||
{t('chat.chatInput.worktreeNew')}
|
||||
</button>
|
||||
</div>
|
||||
{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}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</MobileOverlayPanel>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,7 +31,12 @@ export const MobileAgentButton: React.FC<MobileAgentButtonProps> = ({ onCycleAge
|
||||
const longPressTimerRef = React.useRef<ReturnType<typeof setTimeout> | 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<MobileAgentButtonProps> = ({ 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',
|
||||
|
||||
@@ -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<ComposerDictationProps> = ({
|
||||
disabled,
|
||||
onInsert,
|
||||
onInsertAndSend,
|
||||
onActiveChange,
|
||||
renderTrigger = true,
|
||||
topAccessory,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
@@ -167,6 +178,14 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
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<ComposerDictationProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => {
|
||||
void startDictation();
|
||||
}}
|
||||
disabled={disabled || isActive}
|
||||
title={dictationShortcut ? `${t('chat.dictation.start')} (${dictationShortcut})` : t('chat.dictation.start')}
|
||||
aria-label={t('chat.dictation.start')}
|
||||
>
|
||||
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
{renderTrigger ? (
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => {
|
||||
void startDictation();
|
||||
}}
|
||||
disabled={disabled || isActive}
|
||||
title={dictationShortcut ? `${t('chat.dictation.start')} (${dictationShortcut})` : t('chat.dictation.start')}
|
||||
aria-label={t('chat.dictation.start')}
|
||||
>
|
||||
<Icon name="mic" className={cn(iconSizeClass, 'text-current')} />
|
||||
</button>
|
||||
) : null}
|
||||
{isActive ? (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
// overflow-x/y split on purpose: mobile.css rewrites the
|
||||
// shorthand `.overflow-hidden` to overflow-y:auto on touch
|
||||
// devices, which painted a phantom scrollbar on Android.
|
||||
className="absolute inset-0 z-50 flex flex-col overflow-x-hidden overflow-y-hidden"
|
||||
className={cn(
|
||||
'absolute inset-0 z-50 flex flex-col overflow-x-hidden overflow-y-hidden',
|
||||
// Mobile: the overlay surface shows instantly (riding the
|
||||
// pill → voice morph), its content fades in only after the
|
||||
// shape has grown — otherwise the controls paint clipped
|
||||
// inside the still-small pill.
|
||||
isMobile && 'oc-composer-morph-content-fade',
|
||||
)}
|
||||
style={{
|
||||
borderRadius: radius,
|
||||
// Must match the composer box background exactly so the
|
||||
@@ -289,6 +330,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
role="dialog"
|
||||
aria-label={t('chat.dictation.overlayAria')}
|
||||
>
|
||||
{topAccessory}
|
||||
<div
|
||||
className={cn(
|
||||
// Text paddings match the composer textarea, plus the
|
||||
@@ -354,6 +396,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={cn(footerIconButtonClass, 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => {
|
||||
void cancelDictation();
|
||||
@@ -365,6 +408,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => confirmWith('insert')}
|
||||
title={t('chat.dictation.insert')}
|
||||
@@ -374,6 +418,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={cn(footerIconButtonClass, 'text-primary hover:text-primary')}
|
||||
onClick={() => confirmWith('send')}
|
||||
title={t('chat.dictation.insertAndSend')}
|
||||
@@ -385,6 +430,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
) : status === 'uploading' ? (
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={cn(footerIconButtonClass, 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={() => {
|
||||
void cancelDictation();
|
||||
@@ -398,6 +444,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={cn(footerIconButtonClass, 'text-muted-foreground hover:text-foreground')}
|
||||
onClick={discardFailedDictation}
|
||||
title={t('chat.dictation.discard')}
|
||||
@@ -407,6 +454,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={footerIconButtonClass}
|
||||
onClick={retry}
|
||||
title={t('chat.dictation.retry')}
|
||||
@@ -417,6 +465,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
|
||||
{partialTranscript.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
{...keepKeyboardFocusProps}
|
||||
className={footerIconButtonClass}
|
||||
onClick={() => {
|
||||
pendingActionRef.current = 'insert';
|
||||
|
||||
@@ -752,8 +752,13 @@ export const useChatAutoFollow = ({
|
||||
// Only pinned content rides the keyboard; a user reading history
|
||||
// stays put (the composer slides over the bottom, like native apps).
|
||||
if (stateRef.current !== 'following' || !canScroll(el)) return;
|
||||
// Fold any pending re-pin distance into the same slide. The pill
|
||||
// composer swaps to the full composer right before focusing, which
|
||||
// shrinks the viewport without moving scrollTop — compensating it
|
||||
// here makes keyboard + composer growth one motion instead of two.
|
||||
const pending = Math.max(0, el.scrollHeight - el.scrollTop - el.clientHeight);
|
||||
inner.style.transition = transition;
|
||||
inner.style.transform = `translateY(${-detail.slide}px)`;
|
||||
inner.style.transform = `translateY(${-(detail.slide + pending)}px)`;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1920,6 +1920,9 @@ export const dict = {
|
||||
'chat.chatInput.previewContextRemove': 'Remove preview context',
|
||||
'chat.chatInput.projectRoot': 'Project root',
|
||||
'chat.chatInput.branch': 'Branch',
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Project',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Search projects...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Search branches...',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
'chat.chatInput.worktreeNew': '+ New',
|
||||
'chat.chatInput.drop.insertMention': 'Drop to insert as mention',
|
||||
|
||||
@@ -1886,6 +1886,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.previewContextRemove": "Quitar contexto de vista previa",
|
||||
"chat.chatInput.projectRoot": "Raíz del proyecto",
|
||||
"chat.chatInput.branch": "Rama",
|
||||
"chat.chatInput.draftPicker.projectTitle": "Proyecto",
|
||||
"chat.chatInput.draftPicker.searchProjects": "Buscar proyectos...",
|
||||
"chat.chatInput.draftPicker.searchBranches": "Buscar ramas...",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
"chat.chatInput.worktreeNew": "+ Nuevo",
|
||||
"chat.chatInput.drop.insertMention": "Suelta para insertar como mención",
|
||||
|
||||
@@ -1708,6 +1708,9 @@ export const dict = {
|
||||
'chat.chatInput.previewContextRemove': 'Supprimer le contexte d\'aperçu',
|
||||
'chat.chatInput.projectRoot': 'Racine du projet',
|
||||
'chat.chatInput.branch': 'Bifurquer',
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Projet',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Rechercher des projets...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Rechercher des branches...',
|
||||
'chat.chatInput.worktrees': 'Worktrees',
|
||||
'chat.chatInput.worktreeNew': '+ Nouveau',
|
||||
'chat.chatInput.drop.insertMention': 'Déposer pour insérer comme mention',
|
||||
|
||||
@@ -1919,6 +1919,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.previewContextRemove': 'プレビューコンテキストを削除',
|
||||
'chat.chatInput.projectRoot': 'プロジェクトルート',
|
||||
'chat.chatInput.branch': 'ブランチ',
|
||||
'chat.chatInput.draftPicker.projectTitle': 'プロジェクト',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'プロジェクトを検索...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'ブランチを検索...',
|
||||
'chat.chatInput.worktrees': 'ワークツリー',
|
||||
'chat.chatInput.worktreeNew': '+ 新規',
|
||||
'chat.chatInput.drop.insertMention': 'ドロップしてメンションとして挿入',
|
||||
|
||||
@@ -1920,6 +1920,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.previewContextRemove': '미리보기 컨텍스트 제거',
|
||||
'chat.chatInput.projectRoot': '프로젝트 루트',
|
||||
'chat.chatInput.branch': '브랜치',
|
||||
'chat.chatInput.draftPicker.projectTitle': '프로젝트',
|
||||
'chat.chatInput.draftPicker.searchProjects': '프로젝트 검색...',
|
||||
'chat.chatInput.draftPicker.searchBranches': '브랜치 검색...',
|
||||
'chat.chatInput.worktrees': '워크트리',
|
||||
'chat.chatInput.worktreeNew': '+ 새로 만들기',
|
||||
'chat.chatInput.drop.insertMention': '여기에 놓아 멘션으로 추가',
|
||||
|
||||
@@ -1070,6 +1070,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.actions.sendMessageAria': 'Send message',
|
||||
'chat.chatInput.actions.stopGeneratingAria': 'Stop generating',
|
||||
'chat.chatInput.branch': 'Gałąź',
|
||||
'chat.chatInput.draftPicker.projectTitle': 'Projekt',
|
||||
'chat.chatInput.draftPicker.searchProjects': 'Szukaj projektów...',
|
||||
'chat.chatInput.draftPicker.searchBranches': 'Szukaj gałęzi...',
|
||||
'chat.chatInput.devServerLogs': 'Dev Server logs:',
|
||||
'chat.chatInput.devServerLogsRemove': 'Usuń logi serwera deweloperskiego',
|
||||
'chat.chatInput.drop.attachFiles': 'Drop files here to attach',
|
||||
|
||||
@@ -1886,6 +1886,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.previewContextRemove": "Remover contexto da visualização",
|
||||
"chat.chatInput.projectRoot": "Raiz do projeto",
|
||||
"chat.chatInput.branch": "Branch",
|
||||
"chat.chatInput.draftPicker.projectTitle": "Projeto",
|
||||
"chat.chatInput.draftPicker.searchProjects": "Buscar projetos...",
|
||||
"chat.chatInput.draftPicker.searchBranches": "Buscar branches...",
|
||||
"chat.chatInput.worktrees": "Worktrees",
|
||||
"chat.chatInput.worktreeNew": "+ Novo",
|
||||
"chat.chatInput.drop.insertMention": "Solte para inserir como menção",
|
||||
|
||||
@@ -1886,6 +1886,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
"chat.chatInput.previewContextRemove": "Прибрати контекст перегляду",
|
||||
"chat.chatInput.projectRoot": "Корінь проєкту",
|
||||
"chat.chatInput.branch": "гілка",
|
||||
"chat.chatInput.draftPicker.projectTitle": "Проєкт",
|
||||
"chat.chatInput.draftPicker.searchProjects": "Пошук проєктів...",
|
||||
"chat.chatInput.draftPicker.searchBranches": "Пошук гілок...",
|
||||
"chat.chatInput.worktrees": "Worktree",
|
||||
"chat.chatInput.worktreeNew": "+ Новий",
|
||||
"chat.chatInput.drop.insertMention": "Відпустіть, щоб вставити як згадку",
|
||||
|
||||
@@ -1886,6 +1886,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.previewContextRemove': '移除预览上下文',
|
||||
'chat.chatInput.projectRoot': '项目根目录',
|
||||
'chat.chatInput.branch': '分支',
|
||||
'chat.chatInput.draftPicker.projectTitle': '项目',
|
||||
'chat.chatInput.draftPicker.searchProjects': '搜索项目...',
|
||||
'chat.chatInput.draftPicker.searchBranches': '搜索分支...',
|
||||
'chat.chatInput.worktrees': '工作树',
|
||||
'chat.chatInput.worktreeNew': '+ 新建',
|
||||
'chat.chatInput.drop.insertMention': '释放以插入为提及',
|
||||
|
||||
@@ -1890,6 +1890,9 @@ export const dict: Record<I18nKey, string> = {
|
||||
'chat.chatInput.previewContextRemove': '移除預覽上下文',
|
||||
'chat.chatInput.projectRoot': '專案根目錄',
|
||||
'chat.chatInput.branch': '分支',
|
||||
'chat.chatInput.draftPicker.projectTitle': '專案',
|
||||
'chat.chatInput.draftPicker.searchProjects': '搜尋專案...',
|
||||
'chat.chatInput.draftPicker.searchBranches': '搜尋分支...',
|
||||
'chat.chatInput.worktrees': 'Worktree',
|
||||
'chat.chatInput.worktreeNew': '+ 新增',
|
||||
'chat.chatInput.drop.insertMention': '放開以插入為提及',
|
||||
|
||||
@@ -624,3 +624,21 @@
|
||||
:root.oc-capacitor-app.oc-keyboard-open .oc-mobile-composer {
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
|
||||
/* Content cross-fade for the pill ↔ full composer morph. The wrapper animates
|
||||
its height (FLIP in ChatInput); the freshly mounted state fades in on top. */
|
||||
@keyframes oc-composer-morph-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.oc-composer-morph-fade {
|
||||
animation: oc-composer-morph-fade 0.24s ease-out both;
|
||||
}
|
||||
|
||||
/* Voice overlay variant: the overlay BACKGROUND appears immediately (it rides
|
||||
the morphing shape), but its content stays hidden until the shape has mostly
|
||||
finished growing, then fades in — no clipped controls mid-morph. Timings
|
||||
follow the 340ms wrapper morph in ChatInput. */
|
||||
.oc-composer-morph-content-fade > * {
|
||||
animation: oc-composer-morph-fade 0.18s ease-out 0.26s both;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user