diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index d372a0b52..309b2b7ca 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -190,6 +190,7 @@ import { wrapPastedTextChipLabel } from '@/components/mentions/mention-chips'; import { ErrorBoundary } from '@/components/error-boundary'; import { ChatLandingView, type ChatLandingHintType } from './chat-landing-view'; +import { getSessionCreationNavigation } from './submission/use-composer-navigation-focus'; import { BranchSelector, getSelectorTagClassName } from './chat-landing-selectors'; import { extractIssuePRMentionsFromText, @@ -1400,11 +1401,11 @@ function WorkspaceChatLanding({ ); // Auto-focus textarea on mount (desktop only) - const isMobileRef = useRef(isMobile); - isMobileRef.current = isMobile; + const mobileKeyboardRef = useRef(usesMobileKeyboardAction); + mobileKeyboardRef.current = usesMobileKeyboardAction; useEffect(() => { const id = requestAnimationFrame(() => { - if (!isMobileRef.current) { + if (!mobileKeyboardRef.current) { promptTextareaRef.current?.focus(); } }); @@ -3255,10 +3256,9 @@ function WorkspaceChatLanding({ promptTextareaRef.current?.blur(); setMobileNewChatOpen(false); } - await navigate({ - to: '/$workspaceName/sessions/$sessionId', - params: { workspaceName: workspaceSlug, sessionId }, - }); + await navigate( + getSessionCreationNavigation(workspaceSlug, sessionId, usesMobileKeyboardAction) + ); } catch (error) { capturePostHogEvent(postHog, 'session/start_failed', { user_id: userId ?? null, diff --git a/packages/components/src/components/chat/submission/AGENTS.md b/packages/components/src/components/chat/submission/AGENTS.md new file mode 100644 index 000000000..57065c0d8 --- /dev/null +++ b/packages/components/src/components/chat/submission/AGENTS.md @@ -0,0 +1,27 @@ +# Composer submission lifecycle + +`CLAUDE.md` is a symlink to this file. Parent guidelines also apply. + +- `useComposerSubmission` owns one in-flight submission per mounted scope, immediate + mobile blur, and the post-commit focus handoff. Completion is an explicit state + transition, even when pending and completion batch into one render. +- Scope changes and unmount retire submissions. Late completion must not unlock, + clear, or focus a newer composer. Draft persistence stays with the caller. +- The scope's submission token owns both lifetime identity and the synchronous + submission lock; do not mirror it with independent active/finished flags. +- Focus ownership ends when the user focuses/clicks elsewhere or leaves the window; + returning focus to body does not renew that ownership. No timers or focus retries. +- Observe focus and pointer changes in capture phase so child event propagation + cannot hide a focus handoff. Native focus eligibility belongs to the browser. +- Consumers keep the input DOM stable when clearing its value. Mention data and + hydration reset independently of the textarea; only a draft identity change may + remount the mention tree. Verify submission with the real composer, not a textarea mock. +- Pending submission text is a controlled render value; do not also clear the DOM + imperatively while retaining the draft for rejection recovery. +- Creating a session hands desktop focus across navigation through a one-shot + history-state request, claimed by the visible target composer after mounting. + Consume it from history before focusing; ordinary visits, remounts, and Back + must not replay the handoff. Never guess readiness with a timeout. +- Automatic composer focus is desktop-only. Narrow mobile layouts and native + shells (including wide iPads) must not focus on entry or submission completion, + whether the submission succeeds or fails. Explicit user focus actions still work. diff --git a/packages/components/src/components/chat/submission/CLAUDE.md b/packages/components/src/components/chat/submission/CLAUDE.md new file mode 120000 index 000000000..47dc3e3d8 --- /dev/null +++ b/packages/components/src/components/chat/submission/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/packages/components/src/components/chat/submission/use-composer-navigation-focus.ts b/packages/components/src/components/chat/submission/use-composer-navigation-focus.ts new file mode 100644 index 000000000..09cbb2d5b --- /dev/null +++ b/packages/components/src/components/chat/submission/use-composer-navigation-focus.ts @@ -0,0 +1,41 @@ +import { useCallback } from 'react'; +import { useRouter, useRouterState } from '@tanstack/react-router'; + +declare module '@tanstack/react-router' { + interface HistoryState { + focusComposerSessionId?: string; + } +} + +export function getSessionCreationNavigation( + workspaceName: string, + sessionId: string, + usesMobileKeyboard: boolean +) { + return { + to: '/$workspaceName/sessions/$sessionId' as const, + params: { workspaceName, sessionId }, + state: { focusComposerSessionId: usesMobileKeyboard ? undefined : sessionId }, + }; +} + +/** A navigation owns one focus handoff, consumed when its composer actually mounts. */ +export function useComposerNavigationFocus(sessionId: string) { + const router = useRouter(); + const entryKey = useRouterState({ select: (state) => state.location.state.__TSR_key }); + return useCallback(() => { + const location = router.history.location; + if ( + location.state.__TSR_key !== entryKey || + location.state.focusComposerSessionId !== sessionId + ) { + return false; + } + // Consume before focusing: remounts, Back, and reload must not replay it. + router.history.replace(location.href, { + ...location.state, + focusComposerSessionId: undefined, + }); + return true; + }, [entryKey, router, sessionId]); +} diff --git a/packages/components/src/components/chat/submission/use-composer-submission.ts b/packages/components/src/components/chat/submission/use-composer-submission.ts new file mode 100644 index 000000000..4e0f87b5e --- /dev/null +++ b/packages/components/src/components/chat/submission/use-composer-submission.ts @@ -0,0 +1,100 @@ +import { useCallback, useLayoutEffect, useState, type RefObject } from 'react'; + +interface Submission { + isCurrent: () => boolean; + finish: () => void; + dispose: () => void; +} + +function createScope(key: string) { + return { key, submission: null as Submission | null }; +} + +/** + * Owns the local submission lifetime, including its post-commit focus handoff. + * A scope change/unmount retires the whole lifetime, even on an A → B → A trip. + * Callers still own acceptance and draft persistence. + */ +export function useComposerSubmission( + scopeKey: string, + inputRef: RefObject +) { + const [scope, setScope] = useState(() => createScope(scopeKey)); + if (scope.key !== scopeKey) setScope(createScope(scopeKey)); + const [state, setState] = useState<{ + scope: typeof scope; + pending: boolean; + restoreFocus: () => void; + } | null>(null); + + useLayoutEffect(() => { + return () => { + scope.submission?.dispose(); + scope.submission = null; + }; + }, [scope]); + + useLayoutEffect(() => { + if (state?.scope !== scope || state.pending) return; + state.restoreFocus(); + scope.submission?.dispose(); + scope.submission = null; + }, [scope, state]); + + const beginSubmission = useCallback( + ({ dismissKeyboard }: { dismissKeyboard: boolean }): Submission | null => { + // The submission token also covers two submissions in the same React batch. + if (scope.submission) return null; + const input = inputRef.current; + if (!input) return null; + const document = input.ownerDocument; + const window = document.defaultView; + let focusRelinquished = false; + const relinquishFocus = () => { + focusRelinquished = true; + }; + const onFocus = (event: FocusEvent) => { + if (event.target !== inputRef.current && event.target !== document.body) relinquishFocus(); + }; + const onPointerDown = (event: PointerEvent) => { + if (event.target !== inputRef.current) relinquishFocus(); + }; + const dispose = () => { + document.removeEventListener('focusin', onFocus, true); + document.removeEventListener('pointerdown', onPointerDown, true); + window?.removeEventListener('blur', relinquishFocus); + }; + const restoreFocus = () => { + if (!dismissKeyboard && !focusRelinquished) + inputRef.current?.focus({ preventScroll: true }); + }; + const submission: Submission = { + isCurrent: () => scope.submission === submission, + dispose, + finish: () => { + if (!submission.isCurrent()) return; + // A new state object makes completion observable even if React batches + // pending and settled into one commit (including immediate rejection). + setState({ + scope, + pending: false, + restoreFocus, + }); + }, + }; + scope.submission = submission; + if (dismissKeyboard) { + input.blur(); + } else { + document.addEventListener('focusin', onFocus, true); + document.addEventListener('pointerdown', onPointerDown, true); + window?.addEventListener('blur', relinquishFocus); + } + setState({ scope, pending: true, restoreFocus }); + return submission; + }, + [inputRef, scope] + ); + + return { submissionPending: state?.scope === scope && state.pending, beginSubmission }; +} diff --git a/packages/components/src/components/mentions/combined-mention-textarea.tsx b/packages/components/src/components/mentions/combined-mention-textarea.tsx index 46e8c5e8c..e7fe3d7b2 100644 --- a/packages/components/src/components/mentions/combined-mention-textarea.tsx +++ b/packages/components/src/components/mentions/combined-mention-textarea.tsx @@ -798,9 +798,9 @@ export const CombinedMentionTextarea = React.forwardRef< }); }, [externalMentions, internalMentions]); - const [instanceKey, setInstanceKey] = React.useState(0); const prevValueRef = React.useRef(value); - const shouldRefocusRef = React.useRef(false); + const [hydrationKey, setHydrationKey] = React.useState(0); + const [menuOpen, setMenuOpen] = React.useState(false); // A draft swap, applied during render so the outgoing draft's ranges are // never painted over the incoming text — not even for one frame. Remounting @@ -810,46 +810,36 @@ export const CombinedMentionTextarea = React.forwardRef< if (renderedDraftKey !== draftKey) { setRenderedDraftKey(draftKey); setInternalMentions([]); - setInstanceKey((k) => k + 1); + setMenuOpen(false); // The swap is not an edit, so it must not read as one: an incoming empty // draft would otherwise trip the cleared-input reset below and report the // *new* draft's ranges as emptied. prevValueRef.current = value; } + // Clearing content resets data and re-arms hydration, not the input DOM. + // Replacing the textarea here loses browser focus and breaks submission's + // disabled → enabled handoff. Only a different draft replaces the tree. React.useEffect(() => { const prevValue = prevValueRef.current; prevValueRef.current = value; if (!resetOnEmpty) return; if (prevValue !== '' && value === '') { - // Track whether the textarea had focus before the reset so we can restore it - const textarea = ref && typeof ref === 'object' && 'current' in ref ? ref.current : null; - if (textarea && document.activeElement === textarea) { - shouldRefocusRef.current = true; - } setInternalMentions([]); handleMentionValuesChange([]); onExternalMentionsChange?.([]); onMentionRangesChange?.([]); - setInstanceKey((k) => k + 1); + setMenuOpen(false); + setHydrationKey((k) => k + 1); } }, [ handleMentionValuesChange, onExternalMentionsChange, onMentionRangesChange, - ref, resetOnEmpty, value, ]); - // Re-focus the textarea after the Mention tree remounts due to instanceKey change - React.useEffect(() => { - if (!shouldRefocusRef.current) return; - shouldRefocusRef.current = false; - const textarea = ref && typeof ref === 'object' && 'current' in ref ? ref.current : null; - textarea?.focus(); - }, [instanceKey, ref]); - const enableCommandMentions = Boolean(availableCommands && availableCommands.length > 0); const hasExternalMentionSupport = externalMentions.length > 0 || Boolean(onExternalMentionsChange) || Boolean(onMentionClick); @@ -902,7 +892,9 @@ export const CombinedMentionTextarea = React.forwardRef< return ( - - {persistedMentions && persistedMentions.length > 0 ? ( - - ) : null} - - - {mentionActionsRef ? ( - - ) : null} - {enableSkillMentions ? ( - + - ) : null} - {enableIssueMentions ? ( - <> - 0 ? ( + + ) : null} + + + {mentionActionsRef ? ( + + ) : null} + {enableSkillMentions ? ( + - - - ) : null} + ) : null} + {enableIssueMentions ? ( + <> + + + + ) : null} + {label} boolean; session: SessionMeta; sessionLocalProjectRootPath: string | null; isMachineRemoved: boolean; @@ -491,6 +494,7 @@ export const SessionChatInputArea = memo( forwardRef(function SessionChatInputArea( { session, + claimNavigationFocus, sessionLocalProjectRootPath, isMachineRemoved, canStopAgent = false, @@ -574,6 +578,11 @@ export const SessionChatInputArea = memo( const postHog = usePostHog(); const isArchived = session.isArchived === true; const textareaRef = useRef(null); + useLayoutEffect(() => { + if (claimNavigationFocus?.() && !usesMobileKeyboardAction) { + textareaRef.current?.focus({ preventScroll: true }); + } + }, [claimNavigationFocus, usesMobileKeyboardAction]); const agentRoleTurnSelectionRef = useRef(undefined); const selectedAgentRoleRef = useRef(undefined); const agentRoleRunConfigRef = useRef({ @@ -581,11 +590,6 @@ export const SessionChatInputArea = memo( modelId: selectedModelId, configOptionValues: configOptionValues ?? {}, }); - const restoreFocusAfterRejectedMobileSendRef = useRef(false); - /** Session id that initiated the pending desktop focus restore. */ - const pendingDesktopFocusRestoreSessionIdRef = useRef(null); - /** The textarea element that was active when the send started. */ - const pendingFocusRestoreTextareaRef = useRef(null); const attachmentInputRef = useRef(null); const activeSessionIdRef = useRef(session.id); activeSessionIdRef.current = session.id; @@ -777,7 +781,7 @@ export const SessionChatInputArea = memo( // The visible draft can move into an in-flight submission immediately while // its actual state stays intact until the durable writer accepts it. A // rejected send simply reveals the preserved draft again. - const [submissionPending, setSubmissionPending] = useState(false); + const { submissionPending, beginSubmission } = useComposerSubmission(session.id, textareaRef); const expandPromptMentionsRef = useRef< (args: MentionPromptExpansionArgs) => ExpandedMentionPrompt >(({ text }) => ({ text })); @@ -815,7 +819,6 @@ export const SessionChatInputArea = memo( const [prevSessionId, setPrevSessionId] = useState(session.id); if (prevSessionId !== session.id) { setPrevSessionId(session.id); - setSubmissionPending(false); const cached = sessionDraftsCache.get(session.id) ?? initialInputText ?? ''; setUserInputState(cached); setPendingImages(getSessionImageDrafts(session.id)); @@ -1725,263 +1728,202 @@ export const SessionChatInputArea = memo( [pastedTextDrafts, session.id, updatePastedTextDraftsForSession] ); - const sendMessage = useCallback( - async (source: 'keyboard' | 'button' = 'button') => { - if (freeTurnLimitNotice && freeTurnLimitNotice.current >= freeTurnLimitNotice.limit) { - capturePostHogEvent(postHog, 'session/input_blocked', { - reason: 'free_session_turn_limit_reached', - entrypoint: 'session_chat', - project_kind: sessionProjectKind, - workspace_id: workspaceId ?? null, - session_id: session.id, - }); - return; - } - if (isArchived) { - capturePostHogEvent(postHog, 'session/input_blocked', { - reason: 'session_archived', - entrypoint: 'session_chat', - project_kind: sessionProjectKind, - has_pending_images: pendingImages.length > 0, - workspace_id: workspaceId ?? null, - session_id: session.id, - }); - return; - } - if (durableAgentRoleReady === false) { - return; - } - if (isMachineRemoved) { - capturePostHogEvent(postHog, 'session/input_blocked', { - reason: 'machine_removed', - entrypoint: 'session_chat', - project_kind: sessionProjectKind, - has_pending_images: pendingImages.length > 0, - workspace_id: workspaceId ?? null, - session_id: session.id, - }); - return; - } - if (isExternalHistoryRefreshing) { - capturePostHogEvent(postHog, 'session/input_blocked', { - reason: 'external_history_syncing', - entrypoint: 'session_chat', - project_kind: sessionProjectKind, - has_pending_images: pendingImages.length > 0, - workspace_id: workspaceId ?? null, - session_id: session.id, - }); - return; - } - const currentValue = textareaRef.current?.value ?? userInput; - // One pass: pasted placeholders, `$skill`, `@session:`, and the mentions - // that need no rewrite all resolve against the same original text, and - // the spans record where each landed. - const expandedPrompt = expandPromptMentionsRef.current({ - text: currentValue, - mentions: mentionRangesRef.current, - pastedTextDrafts, + const sendMessage = useCallback(async () => { + if (freeTurnLimitNotice && freeTurnLimitNotice.current >= freeTurnLimitNotice.limit) { + capturePostHogEvent(postHog, 'session/input_blocked', { + reason: 'free_session_turn_limit_reached', + entrypoint: 'session_chat', + project_kind: sessionProjectKind, + workspace_id: workspaceId ?? null, + session_id: session.id, }); - const trimmedPrompt = expandedPrompt.text.trim(); - // The trim moves every character left; re-anchor before the offsets ship. - const trimmedSpans = reanchorMessageTextSpansForTrim( - expandedPrompt.text, - trimmedPrompt, - expandedPrompt.spans - ); - const textBlocks: SessionInputBlock[] = trimmedPrompt - ? [ - { - type: 'text', - text: trimmedPrompt, - ...(trimmedSpans ? { spans: trimmedSpans } : {}), - }, - ] - : []; - const uploadedImages = pendingImages - .filter((image): image is PendingImage & { uploaded: SessionImagePayload } => { - return image.status === 'uploaded' && !!image.uploaded; - }) - .map((image) => toImageInputBlock(image.uploaded)); - const hasBlockingImages = pendingImages.some((image) => image.status !== 'uploaded'); - // A still-uploading file (not failed) blocks send; failed ones are - // skipped so a single failed attachment doesn't trap the message. - const hasBlockingFiles = pendingFiles.some((file) => - isSessionFileTransferPhase(file.status) - ); - const uploadedFiles = pendingFiles - .filter((file): file is PendingFile & { uploaded: SessionFilePayload } => { - return file.status === 'uploaded' && !!file.uploaded; - }) - .map((file) => toFileInputBlock(file.uploaded)); - if (hasBlockingImages || hasBlockingFiles) { - capturePostHogEvent(postHog, 'session/input_blocked', { - reason: 'image_upload_in_progress', - entrypoint: 'session_chat', - project_kind: sessionProjectKind, - has_pending_images: true, - workspace_id: workspaceId ?? null, - session_id: session.id, - }); - return; - } - const commentRefBlocks: SessionInputBlock[] = commentReferencesRef.current.map((item) => ({ - type: 'comment_reference' as const, + return; + } + if (isArchived) { + capturePostHogEvent(postHog, 'session/input_blocked', { + reason: 'session_archived', + entrypoint: 'session_chat', + project_kind: sessionProjectKind, + has_pending_images: pendingImages.length > 0, + workspace_id: workspaceId ?? null, + session_id: session.id, + }); + return; + } + if (durableAgentRoleReady === false) { + return; + } + if (isMachineRemoved) { + capturePostHogEvent(postHog, 'session/input_blocked', { + reason: 'machine_removed', + entrypoint: 'session_chat', + project_kind: sessionProjectKind, + has_pending_images: pendingImages.length > 0, + workspace_id: workspaceId ?? null, + session_id: session.id, + }); + return; + } + if (isExternalHistoryRefreshing) { + capturePostHogEvent(postHog, 'session/input_blocked', { + reason: 'external_history_syncing', + entrypoint: 'session_chat', + project_kind: sessionProjectKind, + has_pending_images: pendingImages.length > 0, + workspace_id: workspaceId ?? null, + session_id: session.id, + }); + return; + } + const currentValue = textareaRef.current?.value ?? userInput; + // One pass: pasted placeholders, `$skill`, `@session:`, and the mentions + // that need no rewrite all resolve against the same original text, and + // the spans record where each landed. + const expandedPrompt = expandPromptMentionsRef.current({ + text: currentValue, + mentions: mentionRangesRef.current, + pastedTextDrafts, + }); + const trimmedPrompt = expandedPrompt.text.trim(); + // The trim moves every character left; re-anchor before the offsets ship. + const trimmedSpans = reanchorMessageTextSpansForTrim( + expandedPrompt.text, + trimmedPrompt, + expandedPrompt.spans + ); + const textBlocks: SessionInputBlock[] = trimmedPrompt + ? [ + { + type: 'text', + text: trimmedPrompt, + ...(trimmedSpans ? { spans: trimmedSpans } : {}), + }, + ] + : []; + const uploadedImages = pendingImages + .filter((image): image is PendingImage & { uploaded: SessionImagePayload } => { + return image.status === 'uploaded' && !!image.uploaded; + }) + .map((image) => toImageInputBlock(image.uploaded)); + const hasBlockingImages = pendingImages.some((image) => image.status !== 'uploaded'); + // A still-uploading file (not failed) blocks send; failed ones are + // skipped so a single failed attachment doesn't trap the message. + const hasBlockingFiles = pendingFiles.some((file) => isSessionFileTransferPhase(file.status)); + const uploadedFiles = pendingFiles + .filter((file): file is PendingFile & { uploaded: SessionFilePayload } => { + return file.status === 'uploaded' && !!file.uploaded; + }) + .map((file) => toFileInputBlock(file.uploaded)); + if (hasBlockingImages || hasBlockingFiles) { + capturePostHogEvent(postHog, 'session/input_blocked', { + reason: 'image_upload_in_progress', + entrypoint: 'session_chat', + project_kind: sessionProjectKind, + has_pending_images: true, + workspace_id: workspaceId ?? null, + session_id: session.id, + }); + return; + } + const commentRefBlocks: SessionInputBlock[] = commentReferencesRef.current.map((item) => ({ + type: 'comment_reference' as const, + ...item.reference, + })); + const visualAnnotationRefBlocks: SessionInputBlock[] = + visualAnnotationReferencesRef.current.map((item) => ({ + type: 'visual_annotation_reference' as const, ...item.reference, })); - const visualAnnotationRefBlocks: SessionInputBlock[] = - visualAnnotationReferencesRef.current.map((item) => ({ - type: 'visual_annotation_reference' as const, - ...item.reference, - })); - const submittedVisualAnnotationReferences = visualAnnotationReferencesRef.current.map( - (item) => item.reference - ); + const submittedVisualAnnotationReferences = visualAnnotationReferencesRef.current.map( + (item) => item.reference + ); - if ( - textBlocks.length === 0 && - uploadedImages.length === 0 && - uploadedFiles.length === 0 && - commentRefBlocks.length === 0 && - visualAnnotationRefBlocks.length === 0 - ) { - capturePostHogEvent(postHog, 'session/input_blocked', { - reason: 'empty_input', - entrypoint: 'session_chat', - project_kind: sessionProjectKind, - has_pending_images: false, - workspace_id: workspaceId ?? null, - session_id: session.id, - }); - return; - } + if ( + textBlocks.length === 0 && + uploadedImages.length === 0 && + uploadedFiles.length === 0 && + commentRefBlocks.length === 0 && + visualAnnotationRefBlocks.length === 0 + ) { + capturePostHogEvent(postHog, 'session/input_blocked', { + reason: 'empty_input', + entrypoint: 'session_chat', + project_kind: sessionProjectKind, + has_pending_images: false, + workspace_id: workspaceId ?? null, + session_id: session.id, + }); + return; + } - const inputBlocks: SessionInputBlock[] = [ - ...commentRefBlocks, - ...visualAnnotationRefBlocks, - ...uploadedImages, - ...uploadedFiles, - ...textBlocks, - ]; - const dismissKeyboardForSubmit = - usesMobileKeyboardAction && (source === 'keyboard' || source === 'button'); - // Snapshot the textarea and session id BEFORE the await so the - // post-commit focus-restore effect can verify neither changed during - // the in-flight send. Capturing after the await would miss a session - // switch that happened while the send was pending. - const textareaBeforeSend = textareaRef.current; - const sessionIdBeforeSend = session.id; - if (dismissKeyboardForSubmit) { - // The mobile Send action should dismiss the soft keyboard at the same - // immediate handoff boundary as the visible draft, not after the - // asynchronous local writer accepts the turn. - textareaRef.current?.blur(); - } - setSubmissionPending(true); - // React still owns the preserved draft state. Clear only the visible DOM - // immediately so Enter/click feedback does not wait for local IPC. - if (textareaRef.current) { - textareaRef.current.value = ''; - } - let accepted = false; - try { - accepted = await onSendMessage(inputBlocks, agentRoleTurnSelectionRef.current); - if (accepted) { + const inputBlocks: SessionInputBlock[] = [ + ...commentRefBlocks, + ...visualAnnotationRefBlocks, + ...uploadedImages, + ...uploadedFiles, + ...textBlocks, + ]; + const submittedDraft = { + text: sessionDraftsCache.get(session.id), + images: sessionImageDraftsCache.get(session.id), + files: sessionFileDraftsCache.get(session.id), + pastedText: sessionPastedTextDraftsCache.get(session.id), + }; + const submission = beginSubmission({ dismissKeyboard: usesMobileKeyboardAction }); + if (!submission) return; + try { + const accepted = await onSendMessage(inputBlocks, agentRoleTurnSelectionRef.current); + if (accepted) { + if (submission.isCurrent()) { clearInput(); clearPendingImages(); clearPendingFiles(); updatePastedTextDraftsForSession(session.id, () => []); publishCommentReferences([]); publishVisualAnnotationReferences([]); - if (submittedVisualAnnotationReferences.length > 0) { - void onVisualAnnotationReferencesSubmitted?.(submittedVisualAnnotationReferences); - } + } else if ( + sessionDraftsCache.get(session.id) === submittedDraft.text && + sessionImageDraftsCache.get(session.id) === submittedDraft.images && + sessionFileDraftsCache.get(session.id) === submittedDraft.files && + sessionPastedTextDraftsCache.get(session.id) === submittedDraft.pastedText + ) { + // Acceptance retires the original cached draft even after unmount. + // A later edit owns a different snapshot and must survive. Never + // write component state from a retired submission. + clearSessionChatInputDrafts(session.id); } - } finally { - restoreFocusAfterRejectedMobileSendRef.current = dismissKeyboardForSubmit && !accepted; - if (!dismissKeyboardForSubmit) { - // Stash the pre-send snapshot for the focus-restore effect below. - pendingDesktopFocusRestoreSessionIdRef.current = sessionIdBeforeSend; - pendingFocusRestoreTextareaRef.current = textareaBeforeSend; + if (submittedVisualAnnotationReferences.length > 0) { + void onVisualAnnotationReferencesSubmitted?.(submittedVisualAnnotationReferences); } - setSubmissionPending(false); - // Focus is NOT restored synchronously here: the textarea is still - // disabled (React has not yet committed the re-render that clears - // submissionPending). The desktop focus-restore useEffect below - // handles it after the re-enable render. } - }, - [ - clearInput, - clearPendingImages, - clearPendingFiles, - freeTurnLimitNotice, - isArchived, - durableAgentRoleReady, - isExternalHistoryRefreshing, - isMachineRemoved, - onSendMessage, - onVisualAnnotationReferencesSubmitted, - pendingFiles, - pendingImages, - pastedTextDrafts, - publishCommentReferences, - publishVisualAnnotationReferences, - postHog, - session.id, - sessionProjectKind, - updatePastedTextDraftsForSession, - userInput, - usesMobileKeyboardAction, - workspaceId, - ] - ); - - useEffect(() => { - if (submissionPending || !restoreFocusAfterRejectedMobileSendRef.current) { - return; - } - restoreFocusAfterRejectedMobileSendRef.current = false; - textareaRef.current?.focus(); - }, [submissionPending]); - // Desktop focus restore: runs after submissionPending flips to false AND - // the textarea re-renders as enabled. Verifies that: - // 1. The session has not changed since the send started (compares the - // stored session id with the current one). - // 2. The textarea DOM element is still the one that initiated the send - // (guards against session switches that replace the textarea). - // 3. No other interactive control owns focus. Disabling the textarea - // moves focus to document.body, so body !== textarea is the expected - // state — only skip when a different interactive element has focus. - useEffect(() => { - if (submissionPending) return; - const storedSessionId = pendingDesktopFocusRestoreSessionIdRef.current; - if (storedSessionId === null) return; - pendingDesktopFocusRestoreSessionIdRef.current = null; - - const targetTextarea = pendingFocusRestoreTextareaRef.current; - pendingFocusRestoreTextareaRef.current = null; - - // Session changed while the send was in flight — the current textarea - // belongs to a different session, so do not touch it. - if (storedSessionId !== activeSessionIdRef.current) return; - // The textarea was replaced (e.g. session switch unmounted/remounted). - if (!targetTextarea || textareaRef.current !== targetTextarea) return; - // The user deliberately focused another interactive control during the - // wait. document.body is the default when the disabled textarea lost - // focus, so it does NOT count as the user moving focus elsewhere. - const active = document.activeElement; - if ( - active && - active !== document.body && - active !== targetTextarea && - active instanceof HTMLElement - ) { - return; + } finally { + submission.finish(); } - - targetTextarea.focus(); - }, [submissionPending]); + }, [ + beginSubmission, + clearInput, + clearPendingImages, + clearPendingFiles, + freeTurnLimitNotice, + isArchived, + durableAgentRoleReady, + isExternalHistoryRefreshing, + isMachineRemoved, + onSendMessage, + onVisualAnnotationReferencesSubmitted, + pendingFiles, + pendingImages, + pastedTextDrafts, + publishCommentReferences, + publishVisualAnnotationReferences, + postHog, + session.id, + sessionProjectKind, + updatePastedTextDraftsForSession, + userInput, + usesMobileKeyboardAction, + workspaceId, + ]); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -1998,7 +1940,7 @@ export const SessionChatInputArea = memo( return; } e.preventDefault(); - void sendMessage('keyboard'); + void sendMessage(); }, [mobileKeyboardAction, sendMessage, usesMobileKeyboardAction] ); @@ -2458,7 +2400,7 @@ export const SessionChatInputArea = memo( type="button" size="icon" variant="ghost" - onClick={() => void sendMessage('button')} + onClick={() => void sendMessage()} disabled={!hasSendableContent || isSendActionDisabled} aria-label={ isExternalHistoryRefreshing && externalHistorySyncLabel diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 1bc12d004..1571eb1e6 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -1723,6 +1723,7 @@ export function SessionSearchBar({ } interface SessionChatInterfaceProps { + claimNavigationFocus?: () => boolean; session: SessionMeta; workspaceSession?: SessionMeta | null; className?: string; @@ -1917,6 +1918,7 @@ export const SessionChatInterface = memo( hideMessageArea = false, syncEnabled = !hideMessageArea, isVisible = true, + claimNavigationFocus, isExternalHistoryRefreshing = false, externalHistoryProviderLabel, onNavigateToComment, @@ -5993,6 +5995,7 @@ export const SessionChatInterface = memo( the bottom surface; chat queue is bypassed for the same reason. */} {shouldReplaceComposerWithPermission ? null : ( { const { t } = useTranslation(); const router = useRouter(); + const claimNavigationFocus = useComposerNavigationFocus(sessionId); const postHog = usePostHog(); const isMobile = useIsMobile(); const isZenLayoutMode = useAtomValue(zenLayoutModeAtom); @@ -5098,6 +5100,9 @@ const SessionDetail = ({ > setChatTabRef(tabSession.id, el)} + claimNavigationFocus={ + isActive && tabSession.id === sessionId ? claimNavigationFocus : undefined + } session={tabSession} workspaceSession={activeSession} className="h-full" @@ -5728,6 +5733,8 @@ const SessionDetail = ({ const pendingForkSourceId = pendingForkSourceByTargetSessionId.get(chatSession.id); return { ref: (element: SessionChatInterfaceHandle | null) => setChatTabRef(chatSession.id, element), + claimNavigationFocus: + isActive && chatSession.id === sessionId ? claimNavigationFocus : undefined, session: chatSession, workspaceSession: activeSession, className: 'h-full', diff --git a/packages/components/src/stories/SessionChatInputArea.stories.tsx b/packages/components/src/stories/SessionChatInputArea.stories.tsx index 6886e54d5..ceb1538d8 100644 --- a/packages/components/src/stories/SessionChatInputArea.stories.tsx +++ b/packages/components/src/stories/SessionChatInputArea.stories.tsx @@ -1,6 +1,24 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + Outlet, + RouterProvider, + useRouter, +} from '@tanstack/react-router'; +import { ChatLandingView } from '@/components/chat/chat-landing-view'; +import { + getSessionCreationNavigation, + useComposerNavigationFocus, +} from '@/components/chat/submission/use-composer-navigation-focus'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { isNativeAppShell } from '@/lib/native-platform'; import type { Meta, StoryObj } from '@storybook/react'; import { Provider, createStore } from 'jotai'; +import { createLocalPlatformProvider, createStaticStore } from '@lody/platform'; +import { PlatformContext } from '@lody/platform/react'; import type { AgentConfigId, MachineId, @@ -21,6 +39,18 @@ const STORY_AGENT_CONFIG_ID = 'agent-storybook' as AgentConfigId; const STORY_SESSION_ID = 'session-storybook' as SessionId; const STORY_AUTH_TOKEN = 'storybook-token'; +const storyPlatform = createLocalPlatformProvider({ + session: createStaticStore({ + status: 'authenticated', + user: { id: 'user-storybook', name: 'Storybook user' }, + }), + workspaces: createStaticStore({ + status: 'ready', + workspaces: [{ id: STORY_WORKSPACE_ID, name: 'Storybook', slug: 'storybook', role: 'owner' }], + activeWorkspaceId: STORY_WORKSPACE_ID, + }), +}); + const storyMachineViewMeta: MachineViewMeta = { id: STORY_MACHINE_ID, name: 'Storybook Machine', @@ -34,6 +64,8 @@ type StoryShellProps = { isAgentBusy: boolean; initialInputText?: string; showFreeTurnLimitNotice?: boolean; + onSendMessage?: () => Promise; + claimNavigationFocus?: () => boolean; }; function createStoryStore() { @@ -50,6 +82,8 @@ function StoryShell({ isAgentBusy, initialInputText = '', showFreeTurnLimitNotice = false, + onSendMessage = async () => true, + claimNavigationFocus, }: StoryShellProps) { const store = useMemo(() => createStoryStore(), []); const session = useMemo( @@ -77,43 +111,46 @@ function StoryShell({ ); return ( - -
-
-
- {}, - } - : null - } - onModeChange={() => {}} - onModelChange={() => {}} - onSendMessage={async () => true} - onStop={() => {}} - onRemoveQueueItem={async () => {}} - initialInputText={initialInputText} - disableImageUpload - /> + + +
+
+
+ {}, + } + : null + } + onModeChange={() => {}} + onModelChange={() => {}} + onSendMessage={onSendMessage} + onStop={() => {}} + onRemoveQueueItem={async () => {}} + initialInputText={initialInputText} + disableImageUpload + /> +
-
-
+ +
); } @@ -166,3 +203,104 @@ export const FreeTurnLimitNoticeDark: Story = { theme: 'dark', }, }; + +// Explicit acceptance signal lets browser tests exercise the real pending commit +// without network, elapsed-time assumptions, or a fake composer. +export const DeferredSubmission: Story = { + args: { + onSendMessage: () => + new Promise((resolve) => { + window.addEventListener( + 'storybook:submission-result', + (event) => { + resolve((event as CustomEvent).detail); + }, + { once: true } + ); + }), + }, +}; + +function NavigationLanding() { + const router = useRouter(); + const isMobile = useIsMobile(); + const [prompt, setPrompt] = useState(''); + const navigate = () => { + void router.navigate( + getSessionCreationNavigation( + 'storybook', + 'session-storybook-idle', + isMobile || isNativeAppShell() + ) + ); + }; + return ( + <> + { + if (event.key === 'Enter') { + event.preventDefault(); + navigate(); + } + }} + /> + + + ); +} + +function NavigationSession() { + const router = useRouter(); + const claimNavigationFocus = useComposerNavigationFocus('session-storybook-idle'); + const [ready, setReady] = useState(false); + useEffect(() => { + const markReady = () => setReady(true); + window.addEventListener('storybook:composer-ready', markReady); + return () => window.removeEventListener('storybook:composer-ready', markReady); + }, []); + return ( + <> + + {ready ? ( + + ) : ( +

Preparing session

+ )} + + ); +} + +function NavigationStory() { + const router = useMemo(() => { + const rootRoute = createRootRoute({ component: Outlet }); + const landingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: NavigationLanding, + }); + const sessionRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/$workspaceName/sessions/$sessionId', + component: NavigationSession, + }); + return createRouter({ + routeTree: rootRoute.addChildren([landingRoute, sessionRoute]), + history: createMemoryHistory({ initialEntries: ['/'] }), + }); + }, []); + return ( + + + + ); +} + +export const LandingNavigation: Story = { render: () => }; diff --git a/packages/components/tests/e2e/composer-submission-focus.spec.ts b/packages/components/tests/e2e/composer-submission-focus.spec.ts new file mode 100644 index 000000000..8512262fd --- /dev/null +++ b/packages/components/tests/e2e/composer-submission-focus.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; + +for (const source of ['keyboard', 'button'] as const) { + for (const accepted of [true, false]) { + test(`desktop ${source} send keeps focus after acceptance=${accepted}`, async ({ page }) => { + await page.goto( + '/iframe.html?id=sessions-sessionchatinputarea--deferred-submission&viewMode=story' + ); + const input = page.locator('textarea[data-lody-composer-input]'); + await input.fill('Synthetic focus regression draft'); + const original = await input.elementHandle(); + if (source === 'keyboard') await input.press('Enter'); + else await page.getByRole('button', { name: 'Send', exact: true }).click(); + + await expect(input).toBeDisabled(); + await expect(input).toHaveValue(''); + await expect(input).not.toBeFocused(); + await page.evaluate((result) => { + window.dispatchEvent(new CustomEvent('storybook:submission-result', { detail: result })); + }, accepted); + + await expect(input).toBeEnabled(); + await expect(input).toBeFocused(); + await expect(input).toHaveValue(accepted ? '' : 'Synthetic focus regression draft'); + expect(await original!.evaluate((node) => node === document.activeElement)).toBe(true); + await page.keyboard.type(' Next message'); + await expect(input).toHaveValue( + accepted ? ' Next message' : 'Synthetic focus regression draft Next message' + ); + }); + } +} + +for (const stopPropagation of [false, true]) { + test(`completion preserves relinquished focus (stopPropagation=${stopPropagation})`, async ({ + page, + }) => { + await page.goto( + '/iframe.html?id=sessions-sessionchatinputarea--deferred-submission&viewMode=story' + ); + const input = page.locator('textarea[data-lody-composer-input]'); + await input.fill('Synthetic focus regression draft'); + await input.press('Enter'); + await expect(input).toBeDisabled(); + await page.evaluate((stopFocusPropagation) => { + const other = document.createElement('input'); + document.body.appendChild(other); + if (stopFocusPropagation) + other.addEventListener('focusin', (event) => event.stopPropagation()); + other.focus(); + other.blur(); + other.remove(); + window.dispatchEvent(new CustomEvent('storybook:submission-result', { detail: true })); + }, stopPropagation); + await expect(input).toBeEnabled(); + await expect(input).not.toBeFocused(); + }); +} + +for (const platform of ['desktop', 'narrow-browser', 'wide-native'] as const) { + test(`landing navigation hands off focus only on desktop (${platform})`, async ({ page }) => { + if (platform === 'narrow-browser') await page.setViewportSize({ width: 390, height: 844 }); + if (platform === 'wide-native') { + await page.addInitScript(() => { + Object.defineProperty(window, '__LODY_NATIVE__', { configurable: true, value: true }); + }); + } + await page.goto( + '/iframe.html?id=sessions-sessionchatinputarea--landing-navigation&viewMode=story' + ); + const input = page.locator('textarea[data-lody-composer-input]'); + await input.fill('Synthetic new conversation'); + await input.press('Enter'); + await expect(page.getByText('Preparing session')).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new Event('storybook:composer-ready'))); + await expect(input).toBeVisible(); + if (platform === 'desktop') { + await expect(input).toBeFocused(); + await page.keyboard.type('Continue conversation'); + await expect(input).toHaveValue('Continue conversation'); + } else { + await expect(input).not.toBeFocused(); + } + await page.getByRole('button', { name: 'Leave session' }).click(); + await page.getByRole('button', { name: 'Back to session' }).click(); + await expect(page.getByText('Preparing session')).toBeVisible(); + await page.evaluate(() => window.dispatchEvent(new Event('storybook:composer-ready'))); + await expect(input).toBeVisible(); + await expect(input).not.toBeFocused(); + }); +} diff --git a/packages/components/tests/mention-draft-restore.test.tsx b/packages/components/tests/mention-draft-restore.test.tsx index bbe072fdd..f2c215bf1 100644 --- a/packages/components/tests/mention-draft-restore.test.tsx +++ b/packages/components/tests/mention-draft-restore.test.tsx @@ -132,6 +132,7 @@ describe('mention ranges survive leaving and returning to a draft', () => { value: string; persisted?: readonly PersistedMentionRange[]; draftKey?: string; + resetOnEmpty?: boolean; onRanges?: ( ranges: Array<{ start: number; end: number; value: string; kind?: string }> ) => void; @@ -146,12 +147,61 @@ describe('mention ranges survive leaving and returning to a draft', () => { draftKey={props.draftKey} onMentionRangesChange={props.onRanges as never} getMentionChip={getComposerMentionChip} - resetOnEmpty={false} + resetOnEmpty={props.resetOnEmpty ?? false} /> ); }); } + it('closes an open mention menu on external clear and keeps it closed for the next draft', async () => { + knownFileTokens = new Set(['src/app.ts']); + let setText!: React.Dispatch>; + function Composer() { + const [value, setValue] = React.useState(''); + setText = setValue; + return ( + + ); + } + await act(async () => root.render()); + const textarea = container.querySelector('textarea')!; + await act(async () => { + textarea.focus(); + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call( + textarea, + '@' + ); + textarea.setSelectionRange(1, 1); + textarea.dispatchEvent(new Event('input', { bubbles: true })); + }); + expect(textarea.getAttribute('aria-expanded')).toBe('true'); + await act(async () => setText('')); + expect(container.querySelector('textarea')).toBe(textarea); + expect(textarea.getAttribute('aria-expanded')).toBe('false'); + await act(async () => setText('next draft')); + expect(textarea.getAttribute('aria-expanded')).toBe('false'); + }); + + it('clears and rehydrates mention data without replacing or blurring the input', async () => { + knownFileTokens = new Set(['src/app.ts']); + const props = { resetOnEmpty: true }; + await render({ ...props, value: 'look at @src/app.ts' }); + const textarea = container.querySelector('textarea')!; + textarea.focus(); + expect(kinds()).toContain('file'); + await render({ ...props, value: '' }); + expect(container.querySelector('textarea')).toBe(textarea); + expect(document.activeElement).toBe(textarea); + expect(kinds()).toEqual([]); + await render({ ...props, value: 'look at @src/app.ts' }); + expect(container.querySelector('textarea')).toBe(textarea); + expect(kinds()).toContain('file'); + }); + it('restores the range from the persisted draft when the file index is cold', async () => { const text = 'look at @src/app.ts thanks'; knownFileTokens = new Set(['src/app.ts']); diff --git a/packages/components/tests/session-chat-input-submission.test.tsx b/packages/components/tests/session-chat-input-submission.test.tsx index 6f53733d3..cd38a637c 100644 --- a/packages/components/tests/session-chat-input-submission.test.tsx +++ b/packages/components/tests/session-chat-input-submission.test.tsx @@ -1,9 +1,9 @@ // @vitest-environment jsdom -import { act, createElement } from 'react'; +import { act, createElement, createRef, type RefObject } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import type { AgentRole, AgentRoleId, SessionMeta } from '@lody/shared'; +import type { AgentRole, AgentRoleId, SessionMeta, SessionInputBlock } from '@lody/shared'; const sessionAgentRoleState = vi.hoisted(() => ({ control: { @@ -31,35 +31,6 @@ vi.mock('../src/components/mentions/mention-agent-role-source', async (importOri useAgentRoleMentionItems: () => [], })); -vi.mock('../src/components/chat/chat-composer', async () => { - const React = await import('react'); - return { - ChatComposer: (props: { - promptRef?: React.Ref; - promptValue: string; - promptDisabled?: boolean; - onPromptChange: (value: string) => void; - onPromptKeyDown?: React.KeyboardEventHandler; - primaryAction?: React.ReactNode; - footerSelector?: React.ReactNode; - }) => - React.createElement( - React.Fragment, - null, - React.createElement('textarea', { - ref: props.promptRef, - value: props.promptValue, - disabled: props.promptDisabled, - onChange: (event: React.ChangeEvent) => - props.onPromptChange(event.target.value), - onKeyDown: props.onPromptKeyDown, - }), - props.primaryAction, - props.footerSelector - ), - }; -}); - vi.mock('../src/components/sessions/desktop-run-config-menu', async () => { const React = await import('react'); return { @@ -88,7 +59,11 @@ vi.mock('../src/hooks/use-code-collab-session-file-provider', () => ({ }), })); -import { SessionChatInputArea } from '../src/components/sessions/session-chat-input-area'; +import { + SessionChatInputArea, + setSessionChatInputTextDraft, + type SessionChatInputAreaHandle, +} from '../src/components/sessions/session-chat-input-area'; import { initI18n } from '../src/i18n'; ( @@ -242,14 +217,19 @@ describe('SessionChatInputArea submission feedback', () => { ); }); - expect(container.querySelector('button')?.disabled).toBe(true); - await act(async () => container.querySelector('button')?.click()); + expect(container.querySelector('button[aria-label="Send"]')?.disabled).toBe( + true + ); + await act(async () => + container.querySelector('button[aria-label="Send"]')?.click() + ); expect(onSendMessage).not.toHaveBeenCalled(); }); afterEach(async () => { await act(async () => root?.unmount()); Reflect.deleteProperty(window, '__LODY_NATIVE__'); + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1024 }); root = null; container?.remove(); container = null; @@ -296,7 +276,7 @@ describe('SessionChatInputArea submission feedback', () => { expect(container.querySelector('textarea')?.value).toBe('preserved draft'); await act(async () => { - container?.querySelector('button')?.click(); + container?.querySelector('button[aria-label="Send"]')?.click(); await Promise.resolve(); }); @@ -312,12 +292,7 @@ describe('SessionChatInputArea submission feedback', () => { expect(container.querySelector('textarea')?.disabled).toBe(false); }); - it('dismisses the mobile keyboard for keyboard and button sends', async () => { - let acceptance = deferredBoolean(); - Object.defineProperty(window, '__LODY_NATIVE__', { - configurable: true, - value: true, - }); + it('shows a turn limit without an upgrade action when none is provided', async () => { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -326,7 +301,7 @@ describe('SessionChatInputArea submission feedback', () => { root?.render( createElement(SessionChatInputArea, { session: { - id: 'session-mobile-keyboard-send', + id: 'session-limit', userId: 'user-1', machineId: 'machine-1', cliType: 'builtin', @@ -346,82 +321,51 @@ describe('SessionChatInputArea submission feedback', () => { modelOptions: [], onModeChange: () => undefined, onModelChange: () => undefined, - onSendMessage: () => acceptance.promise, + onSendMessage: async () => true, onStop: () => undefined, onRemoveQueueItem: async () => undefined, - initialInputText: 'send from keyboard', - }) - ); - }); - - const textarea = container.querySelector('textarea'); - const blurSpy = vi.spyOn(textarea!, 'blur'); - textarea?.focus(); - expect(document.activeElement).toBe(textarea); - - await act(async () => { - textarea?.dispatchEvent( - new KeyboardEvent('keydown', { - key: 'Enter', - bubbles: true, - cancelable: true, + freeTurnLimitNotice: { current: 20, limit: 20 }, }) ); - await Promise.resolve(); }); - expect(blurSpy).toHaveBeenCalledOnce(); - expect(document.activeElement).not.toBe(textarea); - expect(textarea?.value).toBe(''); - expect(textarea?.disabled).toBe(true); - - await act(async () => { - acceptance.resolve(false); - await acceptance.promise; - }); - - expect(textarea?.value).toBe('send from keyboard'); - expect(textarea?.disabled).toBe(false); - expect(document.activeElement).toBe(textarea); - - acceptance = deferredBoolean(); - - await act(async () => { - container?.querySelector('button')?.click(); - await Promise.resolve(); - }); - - expect(blurSpy).toHaveBeenCalledTimes(2); - expect(document.activeElement).not.toBe(textarea); - expect(textarea?.value).toBe(''); - expect(textarea?.disabled).toBe(true); - - await act(async () => { - acceptance.resolve(true); - await acceptance.promise; - }); - - expect(textarea?.disabled).toBe(false); - expect(document.activeElement).not.toBe(textarea); + expect(container.textContent).toContain('limited to 20 turns'); + expect(container.textContent).not.toContain('Upgrade to Plus'); }); - it('shows a turn limit without an upgrade action when none is provided', async () => { - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - + let nextSession = 0; + async function renderComposer({ + sessionId = `focus-${++nextSession}`, + onSendMessage, + isArchived = false, + composerRef, + claimNavigationFocus, + }: { + sessionId?: string; + onSendMessage: (blocks: SessionInputBlock[]) => Promise; + isArchived?: boolean; + composerRef?: RefObject; + claimNavigationFocus?: () => boolean; + }) { + if (!container) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + } await act(async () => { - root?.render( + root!.render( createElement(SessionChatInputArea, { + ref: composerRef, + claimNavigationFocus, session: { - id: 'session-limit', + id: sessionId, userId: 'user-1', machineId: 'machine-1', cliType: 'builtin', agentType: 'codex', status: { type: 'idle' }, - isArchived: false, - createdAt: '2026-07-19T00:00:00.000Z', + isArchived, + createdAt: '2026-09-05T00:00:00.000Z', } as SessionMeta, sessionLocalProjectRootPath: null, isMachineRemoved: false, @@ -434,90 +378,278 @@ describe('SessionChatInputArea submission feedback', () => { modelOptions: [], onModeChange: () => undefined, onModelChange: () => undefined, - onSendMessage: async () => true, + onSendMessage, onStop: () => undefined, onRemoveQueueItem: async () => undefined, - freeTurnLimitNotice: { current: 20, limit: 20 }, + initialInputText: 'focus regression draft', }) ); }); + return container!.querySelector('textarea')!; + } - expect(container.textContent).toContain('limited to 20 turns'); - expect(container.textContent).not.toContain('Upgrade to Plus'); + async function submit(source: 'keyboard' | 'button') { + await act(async () => { + if (source === 'keyboard') { + container! + .querySelector('textarea')! + .dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ); + } else { + container!.querySelector('button[aria-label="Send"]')!.click(); + } + }); + } + + it.each([ + ['keyboard', true], + ['keyboard', false], + ['button', true], + ['button', false], + ] as const)( + 'restores desktop focus after deferred %s acceptance=%s using the real composer', + async (source, accepted) => { + const acceptance = deferredBoolean(); + const textarea = await renderComposer({ onSendMessage: () => acceptance.promise }); + textarea.focus(); + await submit(source); + expect(container!.querySelector('textarea')).toBe(textarea); + expect(textarea.disabled).toBe(true); + // jsdom leaves disabled controls focused; browsers blur them at this commit. + document.body.tabIndex = -1; + document.body.focus(); + document.body.removeAttribute('tabindex'); + await act(async () => acceptance.resolve(accepted)); + expect(textarea.disabled).toBe(false); + expect(textarea.value).toBe(accepted ? '' : 'focus regression draft'); + expect(document.activeElement).toBe(textarea); + } + ); + + it.each([false, true])( + 'handles immediately settled button sends on mobile=%s', + async (mobile) => { + if (mobile) + Object.defineProperty(window, '__LODY_NATIVE__', { configurable: true, value: true }); + const textarea = await renderComposer({ onSendMessage: async () => false }); + textarea.focus(); + await submit('button'); + expect(textarea.disabled).toBe(false); + expect(textarea.value).toBe('focus regression draft'); + expect(document.activeElement === textarea).toBe(!mobile); + } + ); + + for (const mobilePlatform of ['narrow-browser', 'wide-native'] as const) { + function setMobilePlatform() { + if (mobilePlatform === 'wide-native') { + Object.defineProperty(window, '__LODY_NATIVE__', { configurable: true, value: true }); + } else { + Object.defineProperty(window, 'innerWidth', { configurable: true, value: 390 }); + } + } + it.each(['keyboard', 'button'] as const)( + `never refocuses ${mobilePlatform} after %s submission succeeds or fails`, + async (source) => { + setMobilePlatform(); + for (const accepted of [false, true]) { + const acceptance = deferredBoolean(); + const textarea = await renderComposer({ onSendMessage: () => acceptance.promise }); + textarea.focus(); + await submit(source); + expect(document.activeElement).not.toBe(textarea); + await act(async () => acceptance.resolve(accepted)); + expect(textarea.disabled).toBe(false); + expect(textarea.value).toBe(accepted ? '' : 'focus regression draft'); + expect(document.activeElement).not.toBe(textarea); + } + } + ); + it(`consumes a navigation request without focusing on ${mobilePlatform}`, async () => { + setMobilePlatform(); + let pending = true; + const textarea = await renderComposer({ + onSendMessage: async () => true, + claimNavigationFocus: () => { + const claimed = pending; + pending = false; + return claimed; + }, + }); + expect(pending).toBe(false); + expect(document.activeElement).not.toBe(textarea); + }); + } + + it.each(['focus', 'focus-stopped', 'focus-then-blur', 'pointer', 'window-blur'] as const)( + 'respects focus relinquished via %s while sending', + async (gesture) => { + const acceptance = deferredBoolean(); + const textarea = await renderComposer({ onSendMessage: () => acceptance.promise }); + textarea.focus(); + await submit('keyboard'); + document.body.tabIndex = -1; + document.body.focus(); + document.body.removeAttribute('tabindex'); + const other = document.createElement('button'); + container!.appendChild(other); + if (gesture === 'focus-stopped') { + other.addEventListener('focusin', (event) => event.stopPropagation()); + } + if (gesture.startsWith('focus')) { + other.focus(); + if (gesture === 'focus-then-blur') other.blur(); + } else if (gesture === 'pointer') { + other.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true })); + } else { + window.dispatchEvent(new Event('blur')); + } + await act(async () => acceptance.resolve(true)); + expect(document.activeElement).not.toBe(textarea); + } + ); + + it.each([ + [false, false], + [false, true], + [true, false], + [true, true], + ])( + 'ignores an old completion after a session switch (return=%s, accepted=%s)', + async (returnToA, accepted) => { + const oldAcceptance = deferredBoolean(); + const newAcceptance = deferredBoolean(); + const sessionA = `switch-a-${++nextSession}`; + let textarea = await renderComposer({ + sessionId: sessionA, + onSendMessage: () => oldAcceptance.promise, + }); + textarea.focus(); + await submit('keyboard'); + textarea = await renderComposer({ + sessionId: `switch-b-${nextSession}`, + onSendMessage: () => newAcceptance.promise, + }); + if (returnToA) + textarea = await renderComposer({ + sessionId: sessionA, + onSendMessage: () => newAcceptance.promise, + }); + expect(textarea.disabled).toBe(false); + const newDraft = textarea.value; + await act(async () => oldAcceptance.resolve(accepted)); + expect(textarea.value).toBe(newDraft); + expect(document.activeElement).not.toBe(textarea); + textarea.focus(); + await submit('keyboard'); + expect(textarea.disabled).toBe(true); + document.body.tabIndex = -1; + document.body.focus(); + document.body.removeAttribute('tabindex'); + await act(async () => newAcceptance.resolve(false)); + expect(textarea.disabled).toBe(false); + expect(textarea.value).toBe(newDraft); + expect(document.activeElement).toBe(textarea); + } + ); + + it('does not let an old completion enable another session pending submission', async () => { + const first = deferredBoolean(); + const second = deferredBoolean(); + await renderComposer({ onSendMessage: () => first.promise }); + await submit('keyboard'); + const textarea = await renderComposer({ onSendMessage: () => second.promise }); + await submit('keyboard'); + await act(async () => first.resolve(false)); + expect(textarea.disabled).toBe(true); + expect(textarea.value).toBe(''); + await act(async () => second.resolve(false)); + expect(textarea.disabled).toBe(false); + expect(textarea.value).toBe('focus regression draft'); }); - it('does not restore focus when the session changes during a pending send', async () => { + it('does not focus an archived composer or replay focus after restoring it', async () => { const acceptance = deferredBoolean(); - const sessionA: SessionMeta = { - id: 'session-switch-a', - userId: 'user-1', - machineId: 'machine-1', - cliType: 'builtin', - agentType: 'codex', - status: { type: 'idle' }, - isArchived: false, - createdAt: '2026-07-19T00:00:00.000Z', - } as SessionMeta; - const sessionB: SessionMeta = { - ...sessionA, - id: 'session-switch-b', - }; - - container = document.createElement('div'); - document.body.appendChild(container); - root = createRoot(container); - - const baseProps = (session: SessionMeta) => ({ - session, - sessionLocalProjectRootPath: null, - isMachineRemoved: false, - isAgentBusy: false, - isDark: false, - isEmptyConversation: false, - selectedModeId: null, - selectedModelId: null, - modeOptions: [], - modelOptions: [], - onModeChange: () => undefined, - onModelChange: () => undefined, + const props = { + sessionId: `archived-${++nextSession}`, onSendMessage: () => acceptance.promise, - onStop: () => undefined, - onRemoveQueueItem: async () => undefined, - initialInputText: 'draft before switch', - }); - - await act(async () => { - root?.render(createElement(SessionChatInputArea, baseProps(sessionA))); - }); - - const textarea = container.querySelector('textarea'); - expect(textarea?.value).toBe('draft before switch'); - - // Start the send — the textarea becomes disabled and the session id is - // snapshotted inside sendMessage BEFORE the await. - await act(async () => { - container?.querySelector('button')?.click(); - await Promise.resolve(); + }; + const textarea = await renderComposer(props); + textarea.focus(); + await submit('keyboard'); + document.body.tabIndex = -1; + document.body.focus(); + document.body.removeAttribute('tabindex'); + await renderComposer({ ...props, isArchived: true }); + await act(async () => acceptance.resolve(false)); + expect(document.activeElement).not.toBe(textarea); + await renderComposer(props); + expect(document.activeElement).not.toBe(textarea); + }); + it('accepts an attachment-only draft once when Enter repeats before React commits', async () => { + const acceptance = deferredBoolean(); + const submitted: SessionInputBlock[][] = []; + const composerRef = createRef(); + const textarea = await renderComposer({ + composerRef, + onSendMessage: (blocks) => { + submitted.push(blocks); + return acceptance.promise; + }, }); - - expect(textarea?.disabled).toBe(true); - - // Switch the session prop in-place while the send is still pending. + const comment = { + source: 'lody' as const, + path: 'src/example.ts', + lineNumber: 1, + side: 'additions' as const, + commentBody: 'Synthetic review comment', + authorName: 'Reviewer', + }; await act(async () => { - root?.render(createElement(SessionChatInputArea, baseProps(sessionB))); + composerRef.current!.setInputText(''); + composerRef.current!.addCommentReference(comment); }); - - // The session switch resets submissionPending and loads session B's draft. - // Now resolve the original send. The desktop focus-restore effect must see - // that the stored session id (A) no longer matches the current session (B) - // and skip the focus restore. await act(async () => { - acceptance.resolve(false); - await acceptance.promise; + for (let attempt = 0; attempt < 2; attempt++) { + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }) + ); + } }); + expect(submitted).toEqual([[{ type: 'comment_reference', ...comment }]]); + await act(async () => acceptance.resolve(false)); + expect(container!.textContent).toContain('Synthetic review comment'); + }); - const textareaAfterSwitch = container.querySelector('textarea'); - // Focus must NOT have been restored to the new session's textarea. - expect(document.activeElement).not.toBe(textareaAfterSwitch); + it('retires focus ownership when a pending composer unmounts', async () => { + const acceptance = deferredBoolean(); + const textarea = await renderComposer({ onSendMessage: () => acceptance.promise }); + textarea.focus(); + await submit('keyboard'); + await act(async () => root!.unmount()); + root = null; + const other = document.createElement('input'); + container!.appendChild(other); + other.focus(); + await act(async () => acceptance.resolve(true)); + expect(document.activeElement).toBe(other); + expect(textarea.isConnected).toBe(false); + }); + it('retains a newer cached draft when an old send completes after leaving again', async () => { + const acceptance = deferredBoolean(); + const composerRef = createRef(); + const sessionA = `draft-owner-${++nextSession}`; + setSessionChatInputTextDraft(sessionA as SessionMeta['id'], 'original draft'); + const props = { sessionId: sessionA, composerRef, onSendMessage: () => acceptance.promise }; + await renderComposer(props); + await submit('keyboard'); + await renderComposer({ onSendMessage: async () => true }); + await renderComposer(props); + await act(async () => composerRef.current!.setInputText('newer unsent draft')); + await renderComposer({ onSendMessage: async () => true }); + await act(async () => acceptance.resolve(true)); + const textarea = await renderComposer(props); + expect(textarea.value).toBe('newer unsent draft'); }); });