refactor: replace store-based keyboard action requests with direct dispatcher and simplify agent attention clearing

This commit is contained in:
Mohamed Boudra
2026-03-06 14:49:06 +07:00
parent 885b575d14
commit 5b270444d5
25 changed files with 617 additions and 1355 deletions
@@ -21,7 +21,6 @@ import {
import { Theme } from '@/styles/theme'
import type { DraftCommandConfig } from '@/hooks/use-agent-commands-query'
import { encodeImages } from '@/utils/encode-images'
import { useKeyboardShortcutsStore } from '@/stores/keyboard-shortcuts-store'
import { focusWithRetries } from '@/utils/web-focus'
import { useVoiceOptional } from '@/contexts/voice-context'
import { useToast } from '@/contexts/toast-context'
@@ -38,6 +37,9 @@ import { resolveStatusControlMode } from '@/components/agent-input-area.status-c
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
import { useKeyboardActionHandler } from '@/hooks/use-keyboard-action-handler'
import type { KeyboardActionDefinition } from '@/keyboard/keyboard-action-dispatcher'
import { shouldClearAgentAttention } from '@/utils/agent-attention'
type QueuedMessage = {
id: string
@@ -89,10 +91,6 @@ export function AgentInputArea({
const { theme } = useUnistyles()
const insets = useSafeAreaInsets()
const isScreenFocused = useIsFocused()
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
(s) => s.clearMessageInputActionRequest
)
const { client, isConnected, snapshot } = useHostRuntimeSession(serverId)
const toast = useToast()
@@ -134,10 +132,13 @@ export function AgentInputArea({
const [selectedImages, setSelectedImages] = useState<ImageAttachment[]>([])
const [isCancellingAgent, setIsCancellingAgent] = useState(false)
const [sendError, setSendError] = useState<string | null>(null)
const lastHandledMessageInputActionRequestIdRef = useRef<number | null>(null)
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false)
const messageInputRef = useRef<MessageInputRef>(null)
const draftGenerationRef = useRef(0)
const hydratedGenerationRef = useRef(0)
const keyboardHandlerIdRef = useRef(
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`
)
const autocomplete = useAgentAutocomplete({
userInput,
@@ -240,8 +241,18 @@ export function AgentInputArea({
messageId: clientMessageId,
...(imagesData && imagesData.length > 0 ? { images: imagesData } : {}),
})
if (
shouldClearAgentAttention({
agentId,
isConnected,
requiresAttention: agent?.requiresAttention,
attentionReason: agent?.attentionReason,
})
) {
client.clearAgentAttention(agentId)
}
}
}, [client, serverId, setAgentStreamTail, setAgentStreamHead])
}, [agent?.attentionReason, agent?.requiresAttention, client, isConnected, serverId, setAgentStreamTail, setAgentStreamHead])
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage
@@ -513,50 +524,58 @@ export function AgentInputArea({
userInput,
])
// Keyboard-dispatched message-input actions are routed through store requests.
useEffect(() => {
if (!isScreenFocused) return
if (!messageInputActionRequest) return
const currentKey = `${serverId}:${agentId}`
if (messageInputActionRequest.agentKey !== currentKey) {
return
const handleKeyboardAction = useCallback((action: KeyboardActionDefinition): boolean => {
if (!isScreenFocused) {
return false
}
if (lastHandledMessageInputActionRequestIdRef.current === messageInputActionRequest.id) {
return
}
lastHandledMessageInputActionRequestIdRef.current = messageInputActionRequest.id
switch (action.id) {
case 'message-input.focus':
if (Platform.OS !== 'web') {
messageInputRef.current?.focus()
return true
}
if (messageInputActionRequest.kind !== 'focus') {
messageInputRef.current?.runKeyboardAction(messageInputActionRequest.kind)
clearMessageInputActionRequest(messageInputActionRequest.id)
return
focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null
const active = typeof document !== 'undefined' ? document.activeElement : null
return Boolean(el) && active === el
},
})
return true
case 'message-input.dictation-toggle':
messageInputRef.current?.runKeyboardAction('dictation-toggle')
return true
case 'message-input.dictation-cancel':
messageInputRef.current?.runKeyboardAction('dictation-cancel')
return true
case 'message-input.voice-toggle':
messageInputRef.current?.runKeyboardAction('voice-toggle')
return true
case 'message-input.voice-mute-toggle':
messageInputRef.current?.runKeyboardAction('voice-mute-toggle')
return true
default:
return false
}
}, [isScreenFocused])
if (Platform.OS !== 'web') {
messageInputRef.current?.focus()
clearMessageInputActionRequest(messageInputActionRequest.id)
return
}
return focusWithRetries({
focus: () => messageInputRef.current?.focus(),
isFocused: () => {
const el = messageInputRef.current?.getNativeElement?.() ?? null
const active = typeof document !== 'undefined' ? document.activeElement : null
return Boolean(el) && active === el
},
onSuccess: () => clearMessageInputActionRequest(messageInputActionRequest.id),
onTimeout: () => clearMessageInputActionRequest(messageInputActionRequest.id),
})
}, [
agentId,
clearMessageInputActionRequest,
isScreenFocused,
messageInputActionRequest,
serverId,
])
useKeyboardActionHandler({
handlerId: keyboardHandlerIdRef.current,
actions: [
'message-input.focus',
'message-input.dictation-toggle',
'message-input.dictation-cancel',
'message-input.voice-toggle',
'message-input.voice-mute-toggle',
],
enabled: isScreenFocused,
priority: isMessageInputFocused ? 200 : 100,
isActive: () => isScreenFocused,
handle: handleKeyboardAction,
})
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
mode: 'translate',
@@ -803,6 +822,7 @@ export function AgentInputArea({
onSelectionChange={(selection) => {
setCursorIndex(selection.start)
}}
onFocusChange={setIsMessageInputFocused}
/>
</View>
</View>
@@ -83,6 +83,7 @@ export interface MessageInputProps {
onKeyPress?: (event: { key: string; preventDefault: () => void }) => boolean
/** Reports cursor selection updates from the underlying input. */
onSelectionChange?: (selection: { start: number; end: number }) => void
onFocusChange?: (focused: boolean) => void
}
export interface MessageInputRef {
@@ -151,6 +152,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
onSubmitLoadingPress,
onKeyPress: onKeyPressCallback,
onSelectionChange: onSelectionChangeCallback,
onFocusChange,
},
ref
) {
@@ -234,6 +236,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
valueRef.current = value
}, [value])
useEffect(() => {
return () => {
onFocusChange?.(false)
}
}, [onFocusChange])
// Autofocus on web when autoFocus is true, and re-run when focus key changes.
useEffect(() => {
if (!IS_WEB || !autoFocus) return
@@ -707,9 +715,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
placeholderTextColor={theme.colors.mutedForeground}
onFocus={() => {
isInputFocusedRef.current = true
onFocusChange?.(true)
}}
onBlur={() => {
isInputFocusedRef.current = false
onFocusChange?.(false)
}}
style={[
styles.textInput,
@@ -1181,7 +1181,7 @@ const styles = StyleSheet.create((theme) => ({
marginBottom: theme.spacing[1],
},
workspaceListContainer: {
marginLeft: theme.spacing[2],
marginLeft: 0,
},
emptyText: {
color: theme.colors.foregroundMuted,
@@ -1192,7 +1192,7 @@ const styles = StyleSheet.create((theme) => ({
projectRow: {
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
marginBottom: theme.spacing[1],
flexDirection: 'row',
@@ -1253,7 +1253,7 @@ const styles = StyleSheet.create((theme) => ({
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
borderRadius: theme.borderRadius.lg,
flexDirection: 'row',
alignItems: 'center',
@@ -1329,6 +1329,7 @@ const styles = StyleSheet.create((theme) => ({
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
opacity: 0.76,
flex: 1,
minWidth: 0,
},