Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
}
});
Expand Down Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions packages/components/src/components/chat/submission/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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]);
}
Original file line number Diff line number Diff line change
@@ -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<HTMLTextAreaElement | null>
) {
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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -902,7 +892,9 @@ export const CombinedMentionTextarea = React.forwardRef<

return (
<Mention
key={instanceKey}
key={draftKey}
open={value !== '' && menuOpen}
onOpenChange={setMenuOpen}
triggers={triggers}
trigger={triggers[0] ?? '@'}
inputValue={value}
Expand All @@ -918,50 +910,52 @@ export const CombinedMentionTextarea = React.forwardRef<
loop
className="w-full"
>
<FileMentionHydrator
text={value}
getKnownPaths={getKnownFileTokens}
enabled={enableFileMentions}
/>
{persistedMentions && persistedMentions.length > 0 ? (
<PersistedMentionHydrator text={value} ranges={persistedMentions} enabled />
) : null}
<SessionMentionHydrator
getKnownFileTokens={getKnownFileTokens}
text={value}
items={sessionItems}
enabled={enableSessionMentions}
/>
<AgentRoleMentionHydrator
getKnownFileTokens={getKnownFileTokens}
text={value}
items={agentRoleItems}
enabled={enableAgentRoleMentions}
/>
{mentionActionsRef ? (
<MentionActionsBridge actionsRef={mentionActionsRef} items={sessionItems} />
) : null}
{enableSkillMentions ? (
<SkillMentionHydrator
<React.Fragment key={hydrationKey}>
<FileMentionHydrator
text={value}
knownTokens={knownSkillTokens}
enabled={skillsActive}
getKnownPaths={getKnownFileTokens}
enabled={enableFileMentions}
/>
) : null}
{enableIssueMentions ? (
<>
<IssuePrMentionHydrator
{persistedMentions && persistedMentions.length > 0 ? (
<PersistedMentionHydrator text={value} ranges={persistedMentions} enabled />
) : null}
<SessionMentionHydrator
getKnownFileTokens={getKnownFileTokens}
text={value}
items={sessionItems}
enabled={enableSessionMentions}
/>
<AgentRoleMentionHydrator
getKnownFileTokens={getKnownFileTokens}
text={value}
items={agentRoleItems}
enabled={enableAgentRoleMentions}
/>
{mentionActionsRef ? (
<MentionActionsBridge actionsRef={mentionActionsRef} items={sessionItems} />
) : null}
{enableSkillMentions ? (
<SkillMentionHydrator
text={value}
knownItems={knownIssuePrItems}
enabled={enableIssueMentions}
/>
<IssuePrMentionTitleHint
repoFullName={githubRepoFullName}
knownItems={knownIssuePrItems}
enabled={enableIssueMentions}
knownTokens={knownSkillTokens}
enabled={skillsActive}
/>
</>
) : null}
) : null}
{enableIssueMentions ? (
<>
<IssuePrMentionHydrator
text={value}
knownItems={knownIssuePrItems}
enabled={enableIssueMentions}
/>
<IssuePrMentionTitleHint
repoFullName={githubRepoFullName}
knownItems={knownIssuePrItems}
enabled={enableIssueMentions}
/>
</>
) : null}
</React.Fragment>
<MentionLabel className="sr-only">{label}</MentionLabel>
<MentionInput
ref={ref}
Expand Down
Loading
Loading