From d76dba56dd20a701b55c8809b3e3155924f07dfc Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Wed, 26 Aug 2026 19:00:36 -0400 Subject: [PATCH 1/6] feat(mobile): improve model picker navigation and favorites --- .../src/components/session-model-controls.tsx | 314 +++++++++++------- 1 file changed, 195 insertions(+), 119 deletions(-) diff --git a/apps/mobile/src/components/session-model-controls.tsx b/apps/mobile/src/components/session-model-controls.tsx index 1a7ece5..ddfbc6b 100644 --- a/apps/mobile/src/components/session-model-controls.tsx +++ b/apps/mobile/src/components/session-model-controls.tsx @@ -1,7 +1,10 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, + KeyboardAvoidingView, Modal, + Platform, Pressable, ScrollView, StyleSheet, @@ -41,8 +44,10 @@ export type SessionModelControlsProps = { }; type PanelKind = 'closed' | 'models' | 'usage'; +type ProviderFilter = 'favorites' | string; const EMPTY_OPTIONS: HermesModelOptions = { model: null, provider: null, providers: [] }; +const FAVORITE_MODELS_KEY = 'brio:favorite-models:v1'; /** * Compact bar above the composer distinguishing "Profile default" from @@ -137,7 +142,9 @@ export function SessionModelControls({ { backgroundColor: override ? colors.warning : colors.success }, ]} /> - ) : null} + ) : ( + + )} {variant === 'inline' ? inlineLabel : summaryLabel} - + - - - - - - Thread settings + + - + onPress={(event) => event.stopPropagation()} + style={[styles.modalSheet, { backgroundColor: colors.background, borderColor: colors.border }]}> + + + + + {panel === 'usage' ? 'Usage' : 'Choose a model'} + + + {draftOverride ? `${draftOverride.provider} · Session override` : 'Using profile default'} + + + + Done + + + {showUsage ? ( + + setPanel('models')} + /> + setPanel('usage')} + /> + + ) : null} + {showUsage && panel === 'usage' ? ( + + ) : ( + + )} - - {showUsage ? ( - - setPanel('models')} - /> - setPanel('usage')} - /> - - ) : null} - {showUsage && panel === 'usage' ? ( - - ) : ( - - )} - + + ); @@ -250,7 +265,8 @@ function ModelPickerPanel({ }) { const colors = useTheme(); const [search, setSearch] = useState(''); - const [providerExpansion, setProviderExpansion] = useState>({}); + const [providerFilter, setProviderFilter] = useState(''); + const [favoriteModels, setFavoriteModels] = useState>(new Set()); // A pending change awaits explicit confirmation when switching models — or // clearing the override back to the profile default — on a thread that // already has traffic (prompt cache may be invalidated). @@ -273,42 +289,59 @@ function ModelPickerPanel({ const appliedProvider = thread.modelOverride?.provider ?? safeOptions.provider ?? null; - // Search filters dynamically but never reorders. A narrowed catalog expands - // matching providers; otherwise the applied and primary providers start open. + useEffect(() => { + void AsyncStorage.getItem(FAVORITE_MODELS_KEY) + .then((raw) => { + if (!raw) return; + const parsed: unknown = JSON.parse(raw); + if (Array.isArray(parsed)) { + setFavoriteModels(new Set(parsed.filter((value): value is string => typeof value === 'string'))); + } + }) + .catch(() => undefined); + }, []); + + useEffect(() => { + if (providerFilter || safeOptions.providers.length === 0) return; + setProviderFilter( + safeOptions.providers.some((provider) => provider.slug === appliedProvider) + ? (appliedProvider ?? safeOptions.providers[0].slug) + : safeOptions.providers[0].slug, + ); + }, [appliedProvider, providerFilter, safeOptions.providers]); + + const favoriteKey = (provider: string, model: string) => `${provider}/${model}`; + + const toggleFavorite = (provider: string, model: string) => { + const key = favoriteKey(provider, model); + setFavoriteModels((current) => { + const next = new Set(current); + if (next.has(key)) next.delete(key); + else next.add(key); + void AsyncStorage.setItem(FAVORITE_MODELS_KEY, JSON.stringify([...next])).catch(() => undefined); + return next; + }); + }; + + // Searching scans every provider. Without a query, the provider rail keeps + // the catalog focused so opening the picker never produces a wall of models. const groups = useMemo(() => { const query = search.trim().toLowerCase(); return safeOptions.providers .map((provider) => ({ provider, - models: - !query || - provider.name.toLowerCase().includes(query) || - provider.slug.toLowerCase().includes(query) - ? (provider.models ?? []) - : (provider.models ?? []).filter((model) => model.toLowerCase().includes(query)), + models: (provider.models ?? []).filter((model) => { + if (query) { + return `${provider.name} ${provider.slug} ${model}`.toLowerCase().includes(query); + } + if (providerFilter === 'favorites') { + return favoriteModels.has(favoriteKey(provider.slug, model)); + } + return provider.slug === providerFilter; + }), })) .filter((group) => group.models.length > 0); - }, [safeOptions.providers, search]); - - function providerIsExpanded(provider: ModelOptionProvider) { - if (search.trim()) return true; - const explicit = providerExpansion[provider.slug]; - if (explicit !== undefined) return explicit; - const providerIdentity = [provider.slug, provider.name, provider.backend_provider] - .filter((value): value is string => typeof value === 'string') - .join(' ') - .toLowerCase(); - return ( - provider.slug === appliedProvider || - providerIdentity.includes('codex') || - providerIdentity.includes('claude') - ); - } - - function toggleProvider(provider: ModelOptionProvider) { - const current = providerIsExpanded(provider); - setProviderExpansion((values) => ({ ...values, [provider.slug]: !current })); - } + }, [favoriteModels, providerFilter, safeOptions.providers, search]); const applySelection = useCallback( async (provider: ModelOptionProvider, model: string) => { @@ -390,7 +423,10 @@ function ModelPickerPanel({ const effortChoices = reasoningEffortChoices(effectiveCaps); return ( - + {optionsLoading && !options ? ( @@ -405,32 +441,52 @@ function ModelPickerPanel({ ) : null} - + + + {search ? ( + setSearch('')}> + × + + ) : null} + {!search.trim() && safeOptions.providers.length > 0 ? ( + + setProviderFilter('favorites')} + /> + {safeOptions.providers.map((provider) => ( + setProviderFilter(provider.slug)} + /> + ))} + + ) : null} + {groups.map(({ provider, models }) => { - const expanded = providerIsExpanded(provider); - const narrowed = search.trim().length > 0; return ( - toggleProvider(provider)} - style={({ pressed }) => [styles.groupHeader, { opacity: pressed ? 0.6 : 1 }]}> - + + {provider.name} - {!narrowed && !expanded ? ( - {models.length} - ) : null} - {!narrowed ? ( - - {expanded ? '⌃' : '⌄'} - - ) : null} - - {expanded ? models.map((model, index) => { + + {models.length} {models.length === 1 ? 'model' : 'models'} + + + {models.map((model, index) => { const unavailable = modelIsUnavailable(provider, model); const selected = provider.slug === effectiveProvider && model === effectiveModel; const isDefault = provider.slug === safeOptions.provider && model === safeOptions.model; + const favorite = favoriteModels.has(favoriteKey(provider.slug, model)); const first = index === 0; const last = index === models.length - 1; return ( @@ -474,30 +526,54 @@ function ModelPickerPanel({ first ? styles.modelRowFirst : null, last ? styles.modelRowLast : styles.modelRowDivider, { - backgroundColor: colors.panel, - borderColor: colors.border, + backgroundColor: selected ? colors.backgroundSelected : colors.panel, + borderColor: selected ? colors.accent : colors.border, opacity: unavailable ? 0.4 : pressed ? 0.72 : 1, }, ]}> - - {model} - - {isDefault ? : null} - {unavailable ? : null} - - {selected ? ( - - ) : null} + {selected ? : null} + + + + {model} + + {isDefault ? : null} + {unavailable ? : null} + + + {provider.name}{selected ? ' · Selected' : ''} + + + { + event.stopPropagation(); + toggleFavorite(provider.slug, model); + }} + style={styles.favoriteButton}> + + {favorite ? '★' : '☆'} + + ); - }) : null} + })} ); })} {!optionsLoading && groups.length === 0 ? ( - - No matching models - + + + {providerFilter === 'favorites' && !search.trim() ? 'No favorites yet' : 'No matching models'} + + + {providerFilter === 'favorites' && !search.trim() + ? 'Tap the star beside any model to keep it close.' + : 'Try a model name or another provider.'} + + ) : null} {effortChoices.length || effectiveCaps?.fast === true ? ( From da942d3c529e5c34a28caf5fe6b9ff5293f56775 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Wed, 26 Aug 2026 19:17:24 -0400 Subject: [PATCH 2/6] feat(mobile): improve chat model selector and composer - Add provider filters, model metadata, favorites, and refreshed picker layout - Keep model controls visible in new-thread composer and add compact context action - Add Hermes empty state guidance --- .../src/components/composer-controls.tsx | 24 ++- .../src/components/session-model-controls.tsx | 196 +++++++++++++----- .../src/features/home/hermes-home-screen.tsx | 114 ++++------ 3 files changed, 211 insertions(+), 123 deletions(-) diff --git a/apps/mobile/src/components/composer-controls.tsx b/apps/mobile/src/components/composer-controls.tsx index 1b4d8aa..5e55ad6 100644 --- a/apps/mobile/src/components/composer-controls.tsx +++ b/apps/mobile/src/components/composer-controls.tsx @@ -306,6 +306,18 @@ export function ComposerControls({ { backgroundColor: colors.panelStrong, borderColor: colors.border }, ]}> + {!expanded ? ( + setPickerOpen(true)} + style={({ pressed }) => [ + styles.compactAttach, + { backgroundColor: colors.backgroundSelected, opacity: pressed ? 0.65 : 1 }, + ]}> + + + + ) : null} setPickerOpen(true)} /> {modelControl} - setCommandsOpen(true)} /> - {history.length > 0 ? setHistoryOpen(true)} /> : null} + setCommandsOpen(true)} /> + {history.length > 0 ? setHistoryOpen(true)} /> : null} {canUndo ? : null} {canRedo ? : null} {active ? onSend('redirect')} tone="warning" /> : null} @@ -489,7 +501,7 @@ const styles = StyleSheet.create({ borderRadius: 999, minHeight: 54, paddingBottom: 5, - paddingLeft: 18, + paddingLeft: 5, paddingRight: 5, paddingTop: 5, }, @@ -500,13 +512,15 @@ const styles = StyleSheet.create({ paddingHorizontal: 14, paddingTop: 14, }, - collapsedInputRow: { alignItems: 'center', flexDirection: 'row' }, + collapsedInputRow: { alignItems: 'center', flexDirection: 'row', gap: 5 }, expandedInputRow: { minHeight: 72 }, input: { flex: 1, fontSize: 16, lineHeight: 23, outlineStyle: 'none' } as never, - inputCollapsed: { height: 36, paddingBottom: 4, paddingTop: 4 }, + inputCollapsed: { height: 44, paddingBottom: 4, paddingHorizontal: 4, paddingTop: 4 }, inputExpanded: { maxHeight: 150, minHeight: 72, paddingHorizontal: 4, paddingVertical: 4 }, composerFooter: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two }, send: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, + compactAttach: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, + compactAttachLabel: { fontSize: 27, fontWeight: '300', lineHeight: 30 }, attachmentList: { gap: Spacing.two, paddingBottom: Spacing.two }, attachmentChip: { alignItems: 'center', borderRadius: 10, borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.two, paddingHorizontal: Spacing.two, paddingVertical: Spacing.one }, completions: { borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, marginBottom: Spacing.two, maxHeight: 220 }, diff --git a/apps/mobile/src/components/session-model-controls.tsx b/apps/mobile/src/components/session-model-controls.tsx index ddfbc6b..63f1059 100644 --- a/apps/mobile/src/components/session-model-controls.tsx +++ b/apps/mobile/src/components/session-model-controls.tsx @@ -19,8 +19,11 @@ import { useTheme } from '@/hooks/use-theme'; import { getModelPreset, setModelPreset } from '@/lib/model-presets'; import { aggregateSessionAnalytics, + modelCapabilityBadges, + modelCostLabel, modelIncompatibilities, modelIsUnavailable, + modelPricingFor, reasoningEffortChoices, selectedCapabilities, type ReasoningEffortChoice, @@ -48,6 +51,7 @@ type ProviderFilter = 'favorites' | string; const EMPTY_OPTIONS: HermesModelOptions = { model: null, provider: null, providers: [] }; const FAVORITE_MODELS_KEY = 'brio:favorite-models:v1'; +const favoriteModelKey = (provider: string, model: string) => `${provider}/${model}`; /** * Compact bar above the composer distinguishing "Profile default" from @@ -248,6 +252,40 @@ function PanelTab({ ); } +function ProviderChip({ + active, + label, + onPress, +}: { + active: boolean; + label: string; + onPress: () => void; +}) { + const colors = useTheme(); + return ( + [ + styles.providerChip, + { + backgroundColor: active ? colors.accent : colors.backgroundElement, + borderColor: active ? colors.accent : colors.border, + opacity: pressed ? 0.7 : 1, + }, + ]}> + + {label} + + + ); +} + function ModelPickerPanel({ onOverrideChange, options, @@ -301,19 +339,14 @@ function ModelPickerPanel({ .catch(() => undefined); }, []); - useEffect(() => { - if (providerFilter || safeOptions.providers.length === 0) return; - setProviderFilter( - safeOptions.providers.some((provider) => provider.slug === appliedProvider) - ? (appliedProvider ?? safeOptions.providers[0].slug) - : safeOptions.providers[0].slug, - ); - }, [appliedProvider, providerFilter, safeOptions.providers]); - - const favoriteKey = (provider: string, model: string) => `${provider}/${model}`; + const activeProviderFilter = providerFilter || ( + safeOptions.providers.some((provider) => provider.slug === appliedProvider) + ? (appliedProvider ?? safeOptions.providers[0]?.slug ?? '') + : (safeOptions.providers[0]?.slug ?? '') + ); const toggleFavorite = (provider: string, model: string) => { - const key = favoriteKey(provider, model); + const key = favoriteModelKey(provider, model); setFavoriteModels((current) => { const next = new Set(current); if (next.has(key)) next.delete(key); @@ -334,14 +367,14 @@ function ModelPickerPanel({ if (query) { return `${provider.name} ${provider.slug} ${model}`.toLowerCase().includes(query); } - if (providerFilter === 'favorites') { - return favoriteModels.has(favoriteKey(provider.slug, model)); + if (activeProviderFilter === 'favorites') { + return favoriteModels.has(favoriteModelKey(provider.slug, model)); } - return provider.slug === providerFilter; + return provider.slug === activeProviderFilter; }), })) .filter((group) => group.models.length > 0); - }, [favoriteModels, providerFilter, safeOptions.providers, search]); + }, [activeProviderFilter, favoriteModels, safeOptions.providers, search]); const applySelection = useCallback( async (provider: ModelOptionProvider, model: string) => { @@ -467,13 +500,13 @@ function ModelPickerPanel({ keyboardShouldPersistTaps="handled" showsHorizontalScrollIndicator={false}> setProviderFilter('favorites')} /> {safeOptions.providers.map((provider) => ( setProviderFilter(provider.slug)} @@ -510,7 +543,13 @@ function ModelPickerPanel({ const selected = provider.slug === effectiveProvider && model === effectiveModel; const isDefault = provider.slug === safeOptions.provider && model === safeOptions.model; - const favorite = favoriteModels.has(favoriteKey(provider.slug, model)); + const favorite = favoriteModels.has(favoriteModelKey(provider.slug, model)); + const metadata = [ + selected ? 'Selected' : null, + provider.backend_provider?.trim() || null, + ...modelCapabilityBadges(selectedCapabilities(safeOptions, provider.slug, model)), + modelCostLabel(modelPricingFor(provider, model)), + ].filter((value): value is string => Boolean(value)); const first = index === 0; const last = index === models.length - 1; return ( @@ -541,7 +580,7 @@ function ModelPickerPanel({ {unavailable ? : null} - {provider.name}{selected ? ' · Selected' : ''} + {metadata.length ? metadata.join(' · ') : provider.name} - {providerFilter === 'favorites' && !search.trim() ? 'No favorites yet' : 'No matching models'} + {activeProviderFilter === 'favorites' && !search.trim() ? 'No favorites yet' : 'No matching models'} - {providerFilter === 'favorites' && !search.trim() + {activeProviderFilter === 'favorites' && !search.trim() ? 'Tap the star beside any model to keep it close.' : 'Try a model name or another provider.'} @@ -760,7 +799,7 @@ function UsagePanel({ }`; return ( - + Current session @@ -949,33 +988,72 @@ const styles = StyleSheet.create({ borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.one, - height: 34, - maxWidth: 190, - paddingHorizontal: Spacing.three, + height: 36, + maxWidth: 220, + paddingHorizontal: 12, }, + inlineMark: { borderRadius: 3, height: 6, width: 6 }, inlineLabel: { flexShrink: 1 }, + inlineDisclosure: { fontSize: 12, lineHeight: 16, marginLeft: 1 }, modalRoot: { flex: 1 }, + modalBackdrop: { + backgroundColor: 'rgba(0,0,0,0.58)', + flex: 1, + justifyContent: 'flex-end', + }, + modalSheet: { + alignSelf: 'center', + borderTopLeftRadius: 28, + borderTopRightRadius: 28, + borderWidth: StyleSheet.hairlineWidth, + height: '90%', + maxWidth: 720, + overflow: 'hidden', + width: '100%', + }, + sheetHandle: { + alignSelf: 'center', + borderRadius: 3, + height: 5, + marginTop: 9, + opacity: 0.8, + width: 42, + }, modalHeader: { alignItems: 'center', - borderBottomWidth: StyleSheet.hairlineWidth, flexDirection: 'row', - justifyContent: 'space-between', - minHeight: 56, - paddingHorizontal: Spacing.three, + gap: Spacing.three, + minHeight: 72, + paddingHorizontal: Spacing.four, + paddingVertical: Spacing.two, + }, + headerCopy: { flex: 1 }, + headerTitle: { fontSize: 20, lineHeight: 25, textAlign: 'left' }, + doneButton: { + alignItems: 'center', + borderRadius: 999, + justifyContent: 'center', + minHeight: 40, + paddingHorizontal: 18, + }, + panelSwitch: { + borderRadius: 12, + flexDirection: 'row', + gap: 2, + marginHorizontal: Spacing.four, + padding: 3, }, - headerAction: { alignItems: 'center', height: 44, justifyContent: 'center', width: 44 }, - headerBack: { fontSize: 34, fontWeight: '300', lineHeight: 38 }, - headerCheck: { fontSize: 19, fontWeight: '700', lineHeight: 24 }, - headerTitle: { flex: 1, fontSize: 18, textAlign: 'left' }, - panelSwitch: { flexDirection: 'row', gap: Spacing.two, paddingHorizontal: Spacing.four, paddingVertical: Spacing.two }, panelTab: { - borderRadius: 10, + alignItems: 'center', + borderRadius: 9, borderWidth: StyleSheet.hairlineWidth, + flex: 1, minHeight: 36, paddingHorizontal: Spacing.three, justifyContent: 'center', }, - pickerContent: { paddingBottom: Spacing.five, paddingTop: 4 }, + panelBody: { flex: 1 }, + pickerContent: { paddingBottom: 40, paddingTop: 4 }, usageContent: { gap: Spacing.two, paddingBottom: Spacing.five, paddingHorizontal: Spacing.four, paddingTop: Spacing.two }, stateRow: { alignItems: 'center', @@ -986,24 +1064,36 @@ const styles = StyleSheet.create({ }, searchBox: { alignItems: 'center', - borderRadius: 12, + borderRadius: 14, + borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', - marginBottom: 8, + gap: Spacing.two, + marginBottom: 10, marginHorizontal: Spacing.four, marginTop: 12, - paddingHorizontal: Spacing.four, + paddingHorizontal: Spacing.three, }, + searchIcon: { fontSize: 21, lineHeight: 24 }, searchInput: { flex: 1, fontSize: 16, minHeight: 44, outlineStyle: 'none' } as never, + providerRail: { gap: Spacing.two, paddingBottom: Spacing.two, paddingHorizontal: Spacing.four }, + providerChip: { + borderRadius: 999, + borderWidth: StyleSheet.hairlineWidth, + justifyContent: 'center', + minHeight: 36, + maxWidth: 210, + paddingHorizontal: 14, + }, providerSection: { width: '100%' }, groupHeader: { alignItems: 'center', flexDirection: 'row', gap: 8, marginHorizontal: Spacing.four, - marginTop: 4, - minHeight: 44, + marginTop: 2, + minHeight: 42, paddingHorizontal: 4, - paddingTop: 8, + paddingTop: 6, }, providerMark: { alignItems: 'center', @@ -1014,22 +1104,26 @@ const styles = StyleSheet.create({ }, providerMarkText: { fontSize: 11, lineHeight: 14 }, providerName: { flex: 1 }, - disclosure: { fontSize: 16, lineHeight: 18, width: 18 }, modelRow: { alignItems: 'center', flexDirection: 'row', - gap: 8, + gap: 10, marginHorizontal: Spacing.four, - minHeight: 44, - paddingHorizontal: Spacing.four, - paddingVertical: 8, + minHeight: 58, + overflow: 'hidden', + paddingHorizontal: Spacing.three, + paddingVertical: 7, }, modelRowFirst: { borderTopLeftRadius: 16, borderTopRightRadius: 16 }, modelRowLast: { borderBottomLeftRadius: 16, borderBottomRightRadius: 16 }, modelRowDivider: { borderBottomWidth: StyleSheet.hairlineWidth }, - modelName: { flexShrink: 1, fontSize: 16, fontWeight: '600', lineHeight: 20 }, + selectionBar: { alignSelf: 'stretch', borderRadius: 2, marginVertical: 3, width: 3 }, + modelCopy: { flex: 1 }, + modelTitleRow: { alignItems: 'center', flexDirection: 'row', gap: 7 }, + modelName: { flexShrink: 1, fontSize: 16, fontWeight: '600', lineHeight: 21 }, modelRowSpacer: { flex: 1 }, - modelCheck: { fontSize: 16, fontWeight: '700', lineHeight: 20 }, + favoriteButton: { alignItems: 'center', height: 42, justifyContent: 'center', width: 42 }, + favoriteIcon: { fontSize: 21, lineHeight: 24 }, badge: { borderRadius: 6, borderWidth: StyleSheet.hairlineWidth, @@ -1037,7 +1131,9 @@ const styles = StyleSheet.create({ paddingHorizontal: 6, paddingVertical: 1, }, - emptyModels: { paddingHorizontal: Spacing.four, paddingVertical: 56, textAlign: 'center' }, + emptyModels: { alignItems: 'center', gap: 4, paddingHorizontal: Spacing.four, paddingVertical: 56 }, + emptyTitle: { textAlign: 'center' }, + emptyDetail: { maxWidth: 280, textAlign: 'center' }, optionsSection: { marginTop: 8, paddingBottom: 12 }, sectionLabel: { paddingBottom: 8, paddingHorizontal: 20, paddingTop: 8 }, optionsCard: { borderRadius: 16, marginHorizontal: Spacing.four, overflow: 'hidden' }, diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index 8b88d72..6ff4742 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -264,7 +264,12 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } New Thread - + + + {startError ? ( @@ -293,68 +298,42 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } textAlignVertical="top" value={draft} /> - {!composerExpanded ? ( - void startNewTask()} - style={({ pressed }) => [ - styles.sendButton, - { - backgroundColor: canStart ? colors.primary : colors.subtleStrong, - opacity: pressed ? 0.65 : 1, - }, - ]}> - - {starting ? '…' : '↑'} - - - ) : null} - {composerExpanded ? ( - - - setNewThreadModels((current) => ({ ...current, [composerKey]: override })) - } - onOpenChange={setModelPickerOpen} - options={modelOptions.data} - optionsError={modelOptions.isError} - optionsLoading={modelOptions.isLoading} - showUsage={false} - thread={newThread} - variant="inline" - /> - - void startNewTask()} - style={({ pressed }) => [ - styles.sendButton, - { - backgroundColor: canStart ? colors.primary : colors.subtleStrong, - opacity: pressed ? 0.65 : 1, - }, + + + setNewThreadModels((current) => ({ ...current, [composerKey]: override })) + } + onOpenChange={setModelPickerOpen} + options={modelOptions.data} + optionsError={modelOptions.isError} + optionsLoading={modelOptions.isLoading} + showUsage={false} + thread={newThread} + variant="inline" + /> + + void startNewTask()} + style={({ pressed }) => [ + styles.sendButton, + { + backgroundColor: canStart ? colors.primary : colors.subtleStrong, + opacity: pressed ? 0.65 : 1, + }, + ]}> + - - {starting ? '…' : '↑'} - - - - ) : null} + {starting ? '…' : '↑'} + + + @@ -640,12 +619,11 @@ const styles = StyleSheet.create({ overflow: 'hidden', }, promptComposerCollapsed: { - borderRadius: T3Radius.pill, - minHeight: 54, - paddingBottom: 5, - paddingLeft: 0, - paddingRight: 5, - paddingTop: 5, + borderRadius: 26, + minHeight: 104, + paddingBottom: 7, + paddingHorizontal: 10, + paddingTop: 8, }, promptComposerExpanded: { borderRadius: 26, @@ -661,7 +639,7 @@ const styles = StyleSheet.create({ fontSize: 17, lineHeight: 24, }, - promptInputCollapsed: { borderWidth: 0, height: 36, minHeight: 36, paddingHorizontal: 18, paddingVertical: 4 }, + promptInputCollapsed: { borderWidth: 0, height: 44, minHeight: 44, paddingHorizontal: T3Spacing.xs, paddingVertical: 4 }, promptInputExpanded: { borderWidth: 0, maxHeight: 150, minHeight: 78, paddingHorizontal: T3Spacing.xs, paddingTop: T3Spacing.xs }, promptFooter: { alignItems: 'center', flexDirection: 'row', gap: T3Spacing.md }, footerSpacer: { flex: 1 }, From 3a93c43d4ab3f1d23ac12147e48ebb0477a28f94 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Wed, 26 Aug 2026 20:28:20 -0400 Subject: [PATCH 3/6] feat(mobile): embed Hermes chats in home screen - Reuse the thread screen for active and new conversations - Add inline session navigation and new-chat controls --- .../src/features/home/hermes-home-screen.tsx | 268 ++++-------------- .../features/threads/hermes-thread-screen.tsx | 13 +- 2 files changed, 64 insertions(+), 217 deletions(-) diff --git a/apps/mobile/src/features/home/hermes-home-screen.tsx b/apps/mobile/src/features/home/hermes-home-screen.tsx index 6ff4742..5ea8c36 100644 --- a/apps/mobile/src/features/home/hermes-home-screen.tsx +++ b/apps/mobile/src/features/home/hermes-home-screen.tsx @@ -3,10 +3,8 @@ import { useRouter, type Href } from 'expo-router'; import { useEffect, useMemo, useState } from 'react'; import { FlatList, - KeyboardAvoidingView, Modal, PanResponder, - Platform, Pressable, RefreshControl, ScrollView, @@ -16,13 +14,12 @@ import { } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { SessionModelControls } from '@/components/session-model-controls'; import { AppText, AppTextInput, EmptyState, StatusDot } from '@/components/t3-ui'; import { SPLIT_LAYOUT_MIN_WIDTH, T3Radius, T3Spacing, T3Typography } from '@/constants/t3-theme'; +import { HermesThreadScreen } from '@/features/threads/hermes-thread-screen'; import { useT3Theme } from '@/hooks/use-t3-theme'; import { getHealth, - getModelOptions, listSessions, searchSessions, type AgentConnection, @@ -37,9 +34,6 @@ import { type HermesProfile, } from '@/lib/profiles'; import { resolveBrioDeepLink } from '@/lib/profiles-model'; -import { modelIncompatibilities, selectedCapabilities } from '@/lib/session-runtime'; -import { useComposerStore } from '@/state/composer-store'; -import type { ChatModelOverride, ChatThread } from '@/state/chat-thread-model'; import { useDeepLinkStore } from '@/state/deep-link-store'; import { useProfileStore } from '@/state/profile-store'; @@ -52,13 +46,8 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } const [historyOpen, setHistoryOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false); const [search, setSearch] = useState(''); - const [startError, setStartError] = useState(''); - const [starting, setStarting] = useState(false); - const [composerFocused, setComposerFocused] = useState(false); - const [modelPickerOpen, setModelPickerOpen] = useState(false); - const [newThreadModels, setNewThreadModels] = useState< - Record - >({}); + const [activeSessionId, setActiveSessionId] = useState('new'); + const [conversationEpoch, setConversationEpoch] = useState(0); const agentId = environmentId(connection); const storedProfiles = useProfileStore((state) => state.activeProfiles); const setActiveProfile = useProfileStore((state) => state.setActiveProfile); @@ -81,12 +70,6 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } ? profileName(profilesQuery.data.active) : DEFAULT_PROFILE_NAME; const activeProfileData = profiles.find((profile) => profile.name === activeProfile); - const composerKey = `${connection.id}:${activeProfile}:new`; - const newThreadModel = newThreadModels[composerKey]; - const composerHydrated = useComposerStore((state) => state.hydrated); - const draft = useComposerStore((state) => state.drafts[composerKey] ?? ''); - const setDraft = useComposerStore((state) => state.setDraft); - const enqueueDraft = useComposerStore((state) => state.enqueueDraft); useEffect(() => { if (!pendingDeepLink || !profilesQuery.data) return; @@ -98,20 +81,17 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } isNamedProfile(resolved.profile) ? resolved.profile : undefined, ); if (!resolved.sessionId) return; - router.push( - `/thread/${encodeURIComponent(resolved.sessionId)}${ - isNamedProfile(resolved.profile) - ? `?profile=${encodeURIComponent(resolved.profile)}` - : '' - }`, - ); + const timer = setTimeout(() => { + setActiveSessionId(resolved.sessionId!); + setConversationEpoch((current) => current + 1); + }, 0); + return () => clearTimeout(timer); }, [ agentId, consumeDeepLink, pendingDeepLink, profiles, profilesQuery.data, - router, setActiveProfile, ]); @@ -120,12 +100,6 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } queryFn: () => getHealth(connection), refetchInterval: 15_000, }); - const modelOptions = useQuery({ - queryKey: ['model-options', connection.id, connection.url, activeProfile], - queryFn: () => getModelOptions(connection, false, activeProfile), - staleTime: 5 * 60_000, - retry: 1, - }); const sessions = useQuery({ queryKey: ['sessions', connection.id, connection.url, activeProfile], queryFn: () => listSessions(connection, 100, activeProfile), @@ -152,21 +126,6 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } ); }, [resultIds, search, sessions.data?.sessions]); const split = width >= SPLIT_LAYOUT_MIN_WIDTH; - const threadPath = (sessionId: string, override?: ChatModelOverride) => { - const params: string[] = []; - if (isNamedProfile(activeProfile)) { - params.push(`profile=${encodeURIComponent(activeProfile)}`); - } - if (override) { - params.push(`provider=${encodeURIComponent(override.provider)}`); - params.push(`model=${encodeURIComponent(override.model)}`); - if (override.reasoningEffort) { - params.push(`effort=${encodeURIComponent(override.reasoningEffort)}`); - } - if (typeof override.fast === 'boolean') params.push(`fast=${String(override.fast)}`); - } - return `/thread/${encodeURIComponent(sessionId)}${params.length ? `?${params.join('&')}` : ''}` as const; - }; const homeSwipe = useMemo( () => @@ -185,26 +144,14 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } [], ); - const startNewTask = async () => { - if (!composerHydrated || !draft.trim() || starting) return; - setStartError(''); - setStarting(true); - try { - const queued = await enqueueDraft(composerKey, 'queue', newThreadModel); - if (!queued) { - setStartError('Write a message for Hermes before starting.'); - return; - } - router.push(threadPath('new', newThreadModel)); - } catch (reason) { - setStartError(reason instanceof Error ? reason.message : 'Could not start this conversation.'); - } finally { - setStarting(false); - } - }; const openThread = (sessionId: string) => { setHistoryOpen(false); - router.push(threadPath(sessionId)); + setActiveSessionId(sessionId); + setConversationEpoch((current) => current + 1); + }; + const startNewChat = () => { + setActiveSessionId('new'); + setConversationEpoch((current) => current + 1); }; const openTool = (href: Href) => { setSettingsOpen(false); @@ -217,126 +164,49 @@ export function HermesHomeScreen({ connection }: { connection: AgentConnection } : health.data?.hermes_ok ? 'online' : 'busy'; - const newThread: ChatThread = { - id: 'new', - profile: activeProfile, - title: 'New Thread', - createdAt: 0, - updatedAt: 0, - messages: [], - ...(newThreadModel ? { modelOverride: newThreadModel } : {}), - }; - const composerDestination = - activeProfileData?.alias_name?.trim() || connection.name?.trim() || 'Hermes'; - const selectedModelBlocked = Boolean( - newThreadModel && - modelIncompatibilities( - selectedCapabilities( - modelOptions.data ?? { model: null, provider: null, providers: [] }, - newThreadModel.provider, - newThreadModel.model, - ), - { vision: false, tools: true }, - ).length, - ); - const canStart = - composerHydrated && Boolean(draft.trim()) && !starting && !selectedModelBlocked; - const composerExpanded = composerFocused || modelPickerOpen; + const activeSession = sessions.data?.sessions.find((session) => session.id === activeSessionId); + const activeTitle = activeSessionId === 'new' + ? 'New chat' + : activeSession?.title?.trim() || 'Hermes'; return ( - - + + setHistoryOpen(true)} + style={({ pressed }) => [styles.backButton, { opacity: pressed ? 0.5 : 1 }]}> + + + {activeTitle} + {activeSessionId !== 'new' ? ( setHistoryOpen(true)} - style={({ pressed }) => [styles.backButton, { opacity: pressed ? 0.5 : 1 }]}> - + onPress={startNewChat} + style={({ pressed }) => [styles.newChatButton, { opacity: pressed ? 0.5 : 1 }]}> + - New Thread - + ) : ( + + )} + - - - - - - {startError ? ( - {startError} - ) : null} - - - setDraft(composerKey, value)} - onBlur={() => setComposerFocused(false)} - onFocus={() => setComposerFocused(true)} - placeholder={`Describe a coding task in ${composerDestination}`} - style={[ - styles.promptInput, - composerExpanded ? styles.promptInputExpanded : styles.promptInputCollapsed, - { backgroundColor: 'transparent', borderColor: 'transparent' }, - ]} - textAlignVertical="top" - value={draft} - /> - - - - setNewThreadModels((current) => ({ ...current, [composerKey]: override })) - } - onOpenChange={setModelPickerOpen} - options={modelOptions.data} - optionsError={modelOptions.isError} - optionsLoading={modelOptions.isLoading} - showUsage={false} - thread={newThread} - variant="inline" - /> - - void startNewTask()} - style={({ pressed }) => [ - styles.sendButton, - { - backgroundColor: canStart ? colors.primary : colors.subtleStrong, - opacity: pressed ? 0.65 : 1, - }, - ]}> - - {starting ? '…' : '↑'} - - - - - - + setActiveSessionId(sessionId)} + profile={activeProfile} + routeSessionId={activeSessionId} + /> setHistoryOpen(false)} visible={historyOpen}> void; profile: string; routeSessionId: string; }) { @@ -734,6 +738,11 @@ export function HermesThreadScreen({ ), ); if (routeSessionId === 'new') { + const acceptedSessionId = result.sessionId ?? sessionId; + if (onSessionCreated) { + onSessionCreated(acceptedSessionId); + return; + } const params: string[] = []; if (isNamedProfile(profile)) params.push(`profile=${encodeURIComponent(profile)}`); const acceptedModelOverride = queuedPrompt.modelOverride ?? modelOverride; @@ -748,7 +757,7 @@ export function HermesThreadScreen({ } } router.replace( - `/thread/${encodeURIComponent(result.sessionId ?? sessionId)}${ + `/thread/${encodeURIComponent(acceptedSessionId)}${ params.length ? `?${params.join('&')}` : '' }`, ); @@ -954,7 +963,7 @@ export function HermesThreadScreen({ {messages.isLoading && feed.length === 0 ? ( From 37c3be7983ed524afae15897c026128e3c52277d Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Wed, 26 Aug 2026 20:48:48 -0400 Subject: [PATCH 4/6] feat(mobile): improve Android keyboard and composer behavior - Handle Android keyboard insets with animated layout adjustments - Auto-grow the multiline composer and refine its controls --- apps/mobile/app.json | 1 + apps/mobile/plugins/with-android-keyboard.js | 12 +++ .../src/components/composer-controls.tsx | 93 +++++++++++++++---- .../features/threads/hermes-thread-screen.tsx | 30 +++++- 4 files changed, 114 insertions(+), 22 deletions(-) create mode 100644 apps/mobile/plugins/with-android-keyboard.js diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 714869c..86df94f 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -51,6 +51,7 @@ ], "expo-secure-store", "./plugins/with-android-cleartext", + "./plugins/with-android-keyboard", "expo-document-picker", [ "expo-image-picker", diff --git a/apps/mobile/plugins/with-android-keyboard.js b/apps/mobile/plugins/with-android-keyboard.js new file mode 100644 index 0000000..c214646 --- /dev/null +++ b/apps/mobile/plugins/with-android-keyboard.js @@ -0,0 +1,12 @@ +const { AndroidConfig, withAndroidManifest } = require('expo/config-plugins'); + +module.exports = function withAndroidKeyboard(config) { + return withAndroidManifest(config, (mod) => { + const activity = AndroidConfig.Manifest.getMainActivityOrThrow(mod.modResults); + activity.$ = activity.$ || {}; + // React Native owns the IME inset so the composer and transcript can move + // together instead of Android panning only the focused text field. + activity.$['android:windowSoftInputMode'] = 'adjustNothing'; + return mod; + }); +}; diff --git a/apps/mobile/src/components/composer-controls.tsx b/apps/mobile/src/components/composer-controls.tsx index 5e55ad6..fd2c42d 100644 --- a/apps/mobile/src/components/composer-controls.tsx +++ b/apps/mobile/src/components/composer-controls.tsx @@ -2,7 +2,16 @@ import { useQuery } from '@tanstack/react-query'; import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; -import { Modal, Platform, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; +import { + LayoutAnimation, + Modal, + Platform, + Pressable, + ScrollView, + StyleSheet, + TextInput, + View, +} from 'react-native'; import { ThemedText } from '@/components/themed-text'; import { Spacing } from '@/constants/theme'; @@ -38,6 +47,7 @@ export function ComposerControls({ draft, history, hydrated, + keyboardVisible, forceExpanded = false, modelControl, onAddAttachment, @@ -58,6 +68,7 @@ export function ComposerControls({ draft: string; history: string[]; hydrated: boolean; + keyboardVisible?: boolean; forceExpanded?: boolean; modelControl?: ReactNode; onAddAttachment: (attachment: ComposerAttachment) => Promise; @@ -73,10 +84,13 @@ export function ComposerControls({ const colors = useTheme(); const incomingShare = useIncomingShareContext(); const processedShare = useRef(null); + const inputRef = useRef(null); + const previousKeyboardVisible = useRef(keyboardVisible); const [pickerOpen, setPickerOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [commandsOpen, setCommandsOpen] = useState(false); const [inputFocused, setInputFocused] = useState(false); + const [inputHeight, setInputHeight] = useState(48); const [uploads, setUploads] = useState([]); const [error, setError] = useState(''); const token = completionToken(draft); @@ -117,6 +131,17 @@ export function ComposerControls({ Boolean(error) || Boolean(incomingShare.error); + useEffect(() => { + const previous = previousKeyboardVisible.current; + previousKeyboardVisible.current = keyboardVisible; + if (previous !== true || keyboardVisible !== false || !inputFocused) return; + const timer = setTimeout(() => { + inputRef.current?.blur(); + setInputFocused(false); + }, 0); + return () => clearTimeout(timer); + }, [inputFocused, keyboardVisible]); + const uploadSources = useCallback(async (sources: AttachmentSource[]) => { setPickerOpen(false); setError(''); @@ -249,6 +274,15 @@ export function ComposerControls({ setError(reason instanceof Error ? reason.message : 'Could not remove attachment'); } }; + const setFocused = (focused: boolean) => { + LayoutAnimation.configureNext({ + duration: 180, + create: { property: LayoutAnimation.Properties.opacity, type: LayoutAnimation.Types.easeInEaseOut }, + update: { type: LayoutAnimation.Types.easeInEaseOut }, + delete: { property: LayoutAnimation.Properties.opacity, type: LayoutAnimation.Types.easeInEaseOut }, + }); + setInputFocused(focused); + }; return ( <> @@ -322,15 +356,24 @@ export function ComposerControls({ accessibilityLabel="Ask Hermes anything" maxLength={20000} multiline - onBlur={() => setInputFocused(false)} + onBlur={() => setFocused(false)} onChangeText={onDraftChange} - onFocus={() => setInputFocused(true)} + onContentSizeChange={(event) => { + const nativeHeight = event.nativeEvent.contentSize.height; + const measuredHeight = Platform.OS === 'android' ? nativeHeight - 32 : nativeHeight; + const nextHeight = Math.min(112, Math.max(48, Math.ceil(measuredHeight))); + setInputHeight(nextHeight); + }} + onFocus={() => setFocused(true)} placeholder={active ? 'Ask a follow-up…' : 'Ask Hermes anything…'} placeholderTextColor={colors.textTertiary} - scrollEnabled={expanded} + ref={inputRef} + scrollEnabled={expanded && inputHeight >= 112} + selectionColor={colors.accent} style={[ styles.input, expanded ? styles.inputExpanded : styles.inputCollapsed, + expanded ? { height: inputHeight } : null, { color: colors.text }, ]} textAlignVertical={expanded ? 'top' : 'center'} @@ -494,30 +537,42 @@ function formatBytes(value: number) { } const styles = StyleSheet.create({ - toolbar: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two, paddingRight: Spacing.two }, - toolbarAction: { borderRadius: 999, minHeight: 34, justifyContent: 'center', paddingHorizontal: 11 }, + toolbar: { + alignItems: 'center', + flexDirection: 'row', + gap: Spacing.two, + paddingRight: Spacing.two, + }, + toolbarAction: { + borderRadius: 999, + justifyContent: 'center', + minHeight: 36, + paddingHorizontal: 12, + }, composer: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' }, composerCollapsed: { borderRadius: 999, - minHeight: 54, - paddingBottom: 5, - paddingLeft: 5, - paddingRight: 5, - paddingTop: 5, + minHeight: 58, + padding: 6, }, composerExpanded: { borderRadius: 26, - minHeight: 140, - paddingBottom: 6, + minHeight: 112, + paddingBottom: 7, paddingHorizontal: 14, - paddingTop: 14, + paddingTop: 12, }, collapsedInputRow: { alignItems: 'center', flexDirection: 'row', gap: 5 }, - expandedInputRow: { minHeight: 72 }, - input: { flex: 1, fontSize: 16, lineHeight: 23, outlineStyle: 'none' } as never, - inputCollapsed: { height: 44, paddingBottom: 4, paddingHorizontal: 4, paddingTop: 4 }, - inputExpanded: { maxHeight: 150, minHeight: 72, paddingHorizontal: 4, paddingVertical: 4 }, - composerFooter: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two }, + expandedInputRow: { minHeight: 48 }, + input: { fontSize: 16, lineHeight: 23, outlineStyle: 'none' } as never, + inputCollapsed: { flex: 1, height: 44, paddingBottom: 4, paddingHorizontal: 4, paddingTop: 4 }, + inputExpanded: { maxHeight: 112, minHeight: 48, paddingHorizontal: 4, paddingVertical: 4 }, + composerFooter: { + alignItems: 'center', + flexDirection: 'row', + gap: Spacing.two, + minHeight: 44, + }, send: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, compactAttach: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, compactAttachLabel: { fontSize: 27, fontWeight: '300', lineHeight: 30 }, diff --git a/apps/mobile/src/features/threads/hermes-thread-screen.tsx b/apps/mobile/src/features/threads/hermes-thread-screen.tsx index 8281629..e2aeb87 100644 --- a/apps/mobile/src/features/threads/hermes-thread-screen.tsx +++ b/apps/mobile/src/features/threads/hermes-thread-screen.tsx @@ -3,13 +3,15 @@ import { useRouter } from 'expo-router'; import { useEffect, useRef, useState } from 'react'; import { FlatList, + Keyboard, KeyboardAvoidingView, + LayoutAnimation, Platform, Pressable, StyleSheet, View, } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { ComposerControls } from '@/components/composer-controls'; import { AppText, AppTextInput, Button, Card, EmptyState } from '@/components/t3-ui'; @@ -122,6 +124,7 @@ export function HermesThreadScreen({ routeSessionId: string; }) { const colors = useT3Theme(); + const safeArea = useSafeAreaInsets(); const router = useRouter(); const queryClient = useQueryClient(); const listRef = useRef>(null); @@ -145,6 +148,7 @@ export function HermesThreadScreen({ const [gatewayActivity, setGatewayActivity] = useState([]); const [gatewayApproval, setGatewayApproval] = useState(null); const [gatewayInput, setGatewayInput] = useState(null); + const [keyboardInset, setKeyboardInset] = useState(0); const runId = useRunStore((state) => state.activeRuns[runKey] ?? null); const setActiveRun = useRunStore((state) => state.setActiveRun); const clearActiveRun = useRunStore((state) => state.clearActiveRun); @@ -180,6 +184,25 @@ export function HermesThreadScreen({ timestamp: number; } | null>(null); + useEffect(() => { + if (Platform.OS !== 'android') return; + const animateInset = (nextInset: number) => { + LayoutAnimation.configureNext({ + duration: 190, + update: { type: LayoutAnimation.Types.easeInEaseOut }, + }); + setKeyboardInset(nextInset); + }; + const shown = Keyboard.addListener('keyboardDidShow', (event) => { + animateInset(Math.max(0, event.endCoordinates.height - safeArea.bottom)); + }); + const hidden = Keyboard.addListener('keyboardDidHide', () => animateInset(0)); + return () => { + shown.remove(); + hidden.remove(); + }; + }, [safeArea.bottom]); + useEffect(() => { if (routeSessionId !== 'new' || !composerHydrated) return; void ensureComposerSessionId(composerKey, generatedSessionId); @@ -962,9 +985,9 @@ export function HermesThreadScreen({ return ( + style={[styles.safe, keyboardInset > 0 && { paddingBottom: keyboardInset }]}> {messages.isLoading && feed.length === 0 ? ( ) : messages.isError && feed.length === 0 ? ( @@ -1062,6 +1085,7 @@ export function HermesThreadScreen({ forceExpanded={modelPickerOpen} history={history} hydrated={composerHydrated} + keyboardVisible={Platform.OS === 'android' ? keyboardInset > 0 : undefined} modelControl={ Date: Fri, 28 Aug 2026 16:17:03 -0400 Subject: [PATCH 5/6] feat(mobile): refine composer sizing and toolbar layout - Reduce composer and input dimensions - Improve compact attachment and toolbar spacing --- .../src/components/composer-controls.tsx | 64 ++++++++++++------- 1 file changed, 41 insertions(+), 23 deletions(-) diff --git a/apps/mobile/src/components/composer-controls.tsx b/apps/mobile/src/components/composer-controls.tsx index fd2c42d..f01df45 100644 --- a/apps/mobile/src/components/composer-controls.tsx +++ b/apps/mobile/src/components/composer-controls.tsx @@ -90,7 +90,7 @@ export function ComposerControls({ const [historyOpen, setHistoryOpen] = useState(false); const [commandsOpen, setCommandsOpen] = useState(false); const [inputFocused, setInputFocused] = useState(false); - const [inputHeight, setInputHeight] = useState(48); + const [inputHeight, setInputHeight] = useState(42); const [uploads, setUploads] = useState([]); const [error, setError] = useState(''); const token = completionToken(draft); @@ -347,7 +347,7 @@ export function ComposerControls({ onPress={() => setPickerOpen(true)} style={({ pressed }) => [ styles.compactAttach, - { backgroundColor: colors.backgroundSelected, opacity: pressed ? 0.65 : 1 }, + { opacity: pressed ? 0.65 : 1 }, ]}> + @@ -361,14 +361,14 @@ export function ComposerControls({ onContentSizeChange={(event) => { const nativeHeight = event.nativeEvent.contentSize.height; const measuredHeight = Platform.OS === 'android' ? nativeHeight - 32 : nativeHeight; - const nextHeight = Math.min(112, Math.max(48, Math.ceil(measuredHeight))); + const nextHeight = Math.min(96, Math.max(42, Math.ceil(measuredHeight))); setInputHeight(nextHeight); }} onFocus={() => setFocused(true)} placeholder={active ? 'Ask a follow-up…' : 'Ask Hermes anything…'} placeholderTextColor={colors.textTertiary} ref={inputRef} - scrollEnabled={expanded && inputHeight >= 112} + scrollEnabled={expanded && inputHeight >= 96} selectionColor={colors.accent} style={[ styles.input, @@ -404,8 +404,9 @@ export function ComposerControls({ contentContainerStyle={styles.toolbar} horizontal keyboardShouldPersistTaps="handled" + style={styles.toolbarScroller} showsHorizontalScrollIndicator={false}> - setPickerOpen(true)} /> + setPickerOpen(true)} /> {modelControl} setCommandsOpen(true)} /> {history.length > 0 ? setHistoryOpen(true)} /> : null} @@ -444,18 +445,30 @@ function AttachmentChip({ detail, name, onRemove, tone = 'normal' }: { detail: s ); } -function ToolbarAction({ accessibilityLabel, disabled, label, onPress, tone = 'normal' }: { accessibilityLabel?: string; disabled?: boolean; label: string; onPress: () => void; tone?: 'normal' | 'warning' }) { +function ToolbarAction({ accessibilityLabel, bare = false, disabled, label, onPress, tone = 'normal' }: { accessibilityLabel?: string; bare?: boolean; disabled?: boolean; label: string; onPress: () => void; tone?: 'normal' | 'warning' }) { const colors = useTheme(); return ( [ styles.toolbarAction, - { backgroundColor: colors.backgroundSelected, opacity: disabled ? 0.35 : pressed ? 0.6 : 1 }, + bare ? styles.toolbarActionBare : null, + { + backgroundColor: bare ? 'transparent' : colors.backgroundSelected, + opacity: disabled ? 0.35 : pressed ? 0.6 : 1, + }, ]}> - {label} + + {label} + ); } @@ -540,41 +553,46 @@ const styles = StyleSheet.create({ toolbar: { alignItems: 'center', flexDirection: 'row', + flexGrow: 1, gap: Spacing.two, + justifyContent: 'space-between', paddingRight: Spacing.two, }, + toolbarScroller: { flex: 1 }, toolbarAction: { borderRadius: 999, justifyContent: 'center', - minHeight: 36, - paddingHorizontal: 12, + minHeight: 34, + paddingHorizontal: 11, }, + toolbarActionBare: { alignItems: 'center', minWidth: 36, paddingHorizontal: 0 }, + toolbarActionBareLabel: { fontSize: 27, fontWeight: '300', lineHeight: 29 }, composer: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' }, composerCollapsed: { borderRadius: 999, - minHeight: 58, - padding: 6, + minHeight: 52, + padding: 4, }, composerExpanded: { - borderRadius: 26, - minHeight: 112, - paddingBottom: 7, - paddingHorizontal: 14, - paddingTop: 12, + borderRadius: 24, + minHeight: 98, + paddingBottom: 5, + paddingHorizontal: 12, + paddingTop: 9, }, collapsedInputRow: { alignItems: 'center', flexDirection: 'row', gap: 5 }, - expandedInputRow: { minHeight: 48 }, + expandedInputRow: { minHeight: 42 }, input: { fontSize: 16, lineHeight: 23, outlineStyle: 'none' } as never, - inputCollapsed: { flex: 1, height: 44, paddingBottom: 4, paddingHorizontal: 4, paddingTop: 4 }, - inputExpanded: { maxHeight: 112, minHeight: 48, paddingHorizontal: 4, paddingVertical: 4 }, + inputCollapsed: { flex: 1, height: 42, paddingBottom: 3, paddingHorizontal: 4, paddingTop: 3 }, + inputExpanded: { maxHeight: 96, minHeight: 42, paddingHorizontal: 4, paddingVertical: 3 }, composerFooter: { alignItems: 'center', flexDirection: 'row', gap: Spacing.two, - minHeight: 44, + minHeight: 40, }, - send: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, - compactAttach: { alignItems: 'center', borderRadius: 22, height: 44, justifyContent: 'center', width: 44 }, + send: { alignItems: 'center', borderRadius: 20, height: 40, justifyContent: 'center', width: 40 }, + compactAttach: { alignItems: 'center', height: 40, justifyContent: 'center', width: 36 }, compactAttachLabel: { fontSize: 27, fontWeight: '300', lineHeight: 30 }, attachmentList: { gap: Spacing.two, paddingBottom: Spacing.two }, attachmentChip: { alignItems: 'center', borderRadius: 10, borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.two, paddingHorizontal: Spacing.two, paddingVertical: Spacing.one }, From 90d483341577fc6ff5b40f2e9c2fb47c81894682 Mon Sep 17 00:00:00 2001 From: Kevin Bravo Date: Fri, 28 Aug 2026 17:13:52 -0400 Subject: [PATCH 6/6] feat(mobile): animate composer expansion and refine keyboard spacing - Replace layout animation with Reanimated transitions - Improve Android keyboard handling and composer spacing --- .../src/components/composer-controls.tsx | 112 +++++++++++------- .../features/threads/hermes-thread-screen.tsx | 11 +- 2 files changed, 81 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/components/composer-controls.tsx b/apps/mobile/src/components/composer-controls.tsx index f01df45..3d4d051 100644 --- a/apps/mobile/src/components/composer-controls.tsx +++ b/apps/mobile/src/components/composer-controls.tsx @@ -3,7 +3,6 @@ import * as DocumentPicker from 'expo-document-picker'; import * as ImagePicker from 'expo-image-picker'; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { - LayoutAnimation, Modal, Platform, Pressable, @@ -12,6 +11,15 @@ import { TextInput, View, } from 'react-native'; +import Animated, { + Easing, + FadeIn, + FadeOut, + interpolate, + useAnimatedStyle, + useDerivedValue, + withTiming, +} from 'react-native-reanimated'; import { ThemedText } from '@/components/themed-text'; import { Spacing } from '@/constants/theme'; @@ -38,6 +46,13 @@ type UploadState = { error?: string; }; +const COMPOSER_MOTION = { + duration: 230, + easing: Easing.bezier(0.2, 0, 0, 1), +}; +const FOOTER_ENTER = FadeIn.duration(140).delay(80).easing(Easing.out(Easing.cubic)); +const FOOTER_EXIT = FadeOut.duration(70).easing(Easing.in(Easing.quad)); + export function ComposerControls({ active, attachments, @@ -130,6 +145,30 @@ export function ComposerControls({ uploads.length > 0 || Boolean(error) || Boolean(incomingShare.error); + const expansionProgress = useDerivedValue( + () => withTiming(expanded ? 1 : 0, COMPOSER_MOTION), + [expanded], + ); + const expandedComposerHeight = Math.max(98, inputHeight + 54); + const composerHeight = useDerivedValue( + () => withTiming(expanded ? expandedComposerHeight : 52, COMPOSER_MOTION), + [expanded, expandedComposerHeight], + ); + const composerAnimatedStyle = useAnimatedStyle(() => ({ + borderRadius: interpolate(expansionProgress.value, [0, 1], [26, 24]), + height: composerHeight.value, + paddingBottom: interpolate(expansionProgress.value, [0, 1], [4, 5]), + paddingHorizontal: interpolate(expansionProgress.value, [0, 1], [4, 12]), + paddingTop: interpolate(expansionProgress.value, [0, 1], [4, 9]), + })); + const compactLeftStyle = useAnimatedStyle(() => ({ + opacity: interpolate(expansionProgress.value, [0, 0.35, 1], [1, 0, 0]), + width: interpolate(expansionProgress.value, [0, 1], [41, 0]), + })); + const compactRightStyle = useAnimatedStyle(() => ({ + opacity: interpolate(expansionProgress.value, [0, 0.35, 1], [1, 0, 0]), + width: interpolate(expansionProgress.value, [0, 1], [45, 0]), + })); useEffect(() => { const previous = previousKeyboardVisible.current; @@ -138,7 +177,7 @@ export function ComposerControls({ const timer = setTimeout(() => { inputRef.current?.blur(); setInputFocused(false); - }, 0); + }, Platform.OS === 'android' ? 180 : 0); return () => clearTimeout(timer); }, [inputFocused, keyboardVisible]); @@ -275,12 +314,7 @@ export function ComposerControls({ } }; const setFocused = (focused: boolean) => { - LayoutAnimation.configureNext({ - duration: 180, - create: { property: LayoutAnimation.Properties.opacity, type: LayoutAnimation.Types.easeInEaseOut }, - update: { type: LayoutAnimation.Types.easeInEaseOut }, - delete: { property: LayoutAnimation.Properties.opacity, type: LayoutAnimation.Types.easeInEaseOut }, - }); + if (focused === inputFocused) return; setInputFocused(focused); }; @@ -333,30 +367,34 @@ export function ComposerControls({ ) : null} - - - {!expanded ? ( + + setPickerOpen(true)} - style={({ pressed }) => [ - styles.compactAttach, - { opacity: pressed ? 0.65 : 1 }, - ]}> + style={({ pressed }) => [styles.compactControlPressable, { opacity: pressed ? 0.65 : 1 }]}> + - ) : null} + setFocused(false)} + onBlur={() => { + if (Platform.OS === 'android' && keyboardVisible) return; + setFocused(false); + }} onChangeText={onDraftChange} onContentSizeChange={(event) => { const nativeHeight = event.nativeEvent.contentSize.height; @@ -379,13 +417,17 @@ export function ComposerControls({ textAlignVertical={expanded ? 'top' : 'center'} value={draft} /> - {!expanded ? ( + onSend('queue')} style={({ pressed }) => [ - styles.send, + styles.sendPressable, { backgroundColor: canSend ? colors.accent : colors.backgroundSelected, opacity: pressed ? 0.72 : 1, @@ -396,10 +438,10 @@ export function ComposerControls({ ↑ - ) : null} + {expanded ? ( - + [styles.send, { backgroundColor: canSend ? colors.accent : colors.backgroundSelected, opacity: pressed ? 0.72 : 1 }]}> - + ) : null} - + setPickerOpen(false)} onDocuments={() => void chooseDocuments()} onImages={() => void chooseImages()} onPhoto={() => void takePhoto()} visible={pickerOpen} /> { onDraftChange(text); setHistoryOpen(false); }} onClose={() => setHistoryOpen(false)} visible={historyOpen} /> @@ -568,23 +610,10 @@ const styles = StyleSheet.create({ toolbarActionBare: { alignItems: 'center', minWidth: 36, paddingHorizontal: 0 }, toolbarActionBareLabel: { fontSize: 27, fontWeight: '300', lineHeight: 29 }, composer: { borderWidth: StyleSheet.hairlineWidth, overflow: 'hidden' }, - composerCollapsed: { - borderRadius: 999, - minHeight: 52, - padding: 4, - }, - composerExpanded: { - borderRadius: 24, - minHeight: 98, - paddingBottom: 5, - paddingHorizontal: 12, - paddingTop: 9, - }, - collapsedInputRow: { alignItems: 'center', flexDirection: 'row', gap: 5 }, - expandedInputRow: { minHeight: 42 }, + composerInputRow: { alignItems: 'center', flexDirection: 'row', minHeight: 42 }, input: { fontSize: 16, lineHeight: 23, outlineStyle: 'none' } as never, inputCollapsed: { flex: 1, height: 42, paddingBottom: 3, paddingHorizontal: 4, paddingTop: 3 }, - inputExpanded: { maxHeight: 96, minHeight: 42, paddingHorizontal: 4, paddingVertical: 3 }, + inputExpanded: { flex: 1, maxHeight: 96, minHeight: 42, paddingHorizontal: 4, paddingVertical: 3 }, composerFooter: { alignItems: 'center', flexDirection: 'row', @@ -592,7 +621,10 @@ const styles = StyleSheet.create({ minHeight: 40, }, send: { alignItems: 'center', borderRadius: 20, height: 40, justifyContent: 'center', width: 40 }, - compactAttach: { alignItems: 'center', height: 40, justifyContent: 'center', width: 36 }, + sendPressable: { alignItems: 'center', borderRadius: 20, height: 40, justifyContent: 'center', width: 40 }, + compactLeftSlot: { height: 40, justifyContent: 'center', overflow: 'hidden' }, + compactRightSlot: { alignItems: 'flex-end', height: 40, justifyContent: 'center', overflow: 'hidden' }, + compactControlPressable: { alignItems: 'center', height: 40, justifyContent: 'center', width: 36 }, compactAttachLabel: { fontSize: 27, fontWeight: '300', lineHeight: 30 }, attachmentList: { gap: Spacing.two, paddingBottom: Spacing.two }, attachmentChip: { alignItems: 'center', borderRadius: 10, borderWidth: StyleSheet.hairlineWidth, flexDirection: 'row', gap: Spacing.two, paddingHorizontal: Spacing.two, paddingVertical: Spacing.one }, diff --git a/apps/mobile/src/features/threads/hermes-thread-screen.tsx b/apps/mobile/src/features/threads/hermes-thread-screen.tsx index e2aeb87..cbdb5d7 100644 --- a/apps/mobile/src/features/threads/hermes-thread-screen.tsx +++ b/apps/mobile/src/features/threads/hermes-thread-screen.tsx @@ -194,7 +194,7 @@ export function HermesThreadScreen({ setKeyboardInset(nextInset); }; const shown = Keyboard.addListener('keyboardDidShow', (event) => { - animateInset(Math.max(0, event.endCoordinates.height - safeArea.bottom)); + animateInset(Math.max(0, event.endCoordinates.height - safeArea.bottom + T3Spacing.sm)); }); const hidden = Keyboard.addListener('keyboardDidHide', () => animateInset(0)); return () => { @@ -1053,7 +1053,14 @@ export function HermesThreadScreen({ ) : null} - + 0 ? T3Spacing.md : 0, + }, + ]}> {queue.length > 0 ? (