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:
Bohdan Triapitsyn
2026-07-04 16:41:17 +03:00
parent 531039b690
commit fb839b66a9
16 changed files with 737 additions and 65 deletions
@@ -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(() => {
+611 -49
View File
@@ -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';
+6 -1
View File
@@ -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;
}
+3
View File
@@ -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',
+3
View File
@@ -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",
+3
View File
@@ -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',
+3
View File
@@ -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': 'ドロップしてメンションとして挿入',
+3
View File
@@ -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': '여기에 놓아 멘션으로 추가',
+3
View File
@@ -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",
+3
View File
@@ -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': '放開以插入為提及',
+18
View File
@@ -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;
}