mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 02:39:42 +02:00
feat(chat): auto-focus compose box on new chat (#5146)
Starting a new chat (⊕ new chat on the Chat tab, or ⊕ / switching agent in the floating window) now pulls keyboard focus into the compose box so the user can type immediately, instead of having to click into it first. Focus is driven by a monotonic `focusRequest` nonce bumped only by the new-chat handlers, so selecting an existing chat or opening one via a `?session=` deep link never steals focus. ContentEditor.focus() latches through to onCreate when the editor is not yet mounted (immediatelyRender: false), so the freshly-mounted compose box focuses on its first frame. Co-authored-by: Lambda <lambda@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
@@ -202,6 +202,7 @@ export function ChatPage() {
|
||||
noAgent={c.noAgent}
|
||||
agentArchived={c.isAgentArchived}
|
||||
agentName={c.activeAgent?.name}
|
||||
focusRequest={c.focusInputRequest}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ const editorProps = vi.hoisted(() => ({
|
||||
}));
|
||||
// Records imperative editor calls so tests can assert whether a commit
|
||||
// scrubbed the editor (clearEditor) or left it intact (fire-and-forget).
|
||||
const editorState = vi.hoisted(() => ({ cleared: 0, blurred: 0 }));
|
||||
const editorState = vi.hoisted(() => ({ cleared: 0, blurred: 0, focused: 0 }));
|
||||
|
||||
vi.mock("../../editor", () => ({
|
||||
useFileDropZone: ({ onDrop }: { onDrop: (files: File[]) => void }) => {
|
||||
@@ -78,7 +78,9 @@ vi.mock("../../editor", () => ({
|
||||
blur: () => {
|
||||
editorState.blurred += 1;
|
||||
},
|
||||
focus: () => {},
|
||||
focus: () => {
|
||||
editorState.focused += 1;
|
||||
},
|
||||
uploadFile: async (file: File) => {
|
||||
uploadingRef.current += 1;
|
||||
try {
|
||||
@@ -150,6 +152,7 @@ beforeEach(() => {
|
||||
editorProps.last = null;
|
||||
editorState.cleared = 0;
|
||||
editorState.blurred = 0;
|
||||
editorState.focused = 0;
|
||||
const state = useChatStore.getState() as unknown as {
|
||||
activeSessionId: string | null;
|
||||
selectedAgentId: string;
|
||||
@@ -202,6 +205,39 @@ function renderInput(props: Partial<React.ComponentProps<typeof ChatInput>> = {}
|
||||
return { onSend, onUploadFile };
|
||||
}
|
||||
|
||||
describe("ChatInput focusRequest", () => {
|
||||
it("focuses the editor when focusRequest becomes a non-zero value (new chat)", () => {
|
||||
const { rerender } = render(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={vi.fn()} agentName="Multica" focusRequest={0} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
// The inert initial value must not steal focus (e.g. a plain deep-link open).
|
||||
expect(editorState.focused).toBe(0);
|
||||
|
||||
// Starting a new chat bumps the nonce — the compose box grabs focus.
|
||||
rerender(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={vi.fn()} agentName="Multica" focusRequest={1} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(editorState.focused).toBe(1);
|
||||
|
||||
// Each subsequent new chat re-focuses.
|
||||
rerender(
|
||||
<I18nProvider locale="en" resources={TEST_RESOURCES}>
|
||||
<ChatInput onSend={vi.fn()} agentName="Multica" focusRequest={2} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
expect(editorState.focused).toBe(2);
|
||||
});
|
||||
|
||||
it("does not focus on mount when focusRequest is undefined or 0", () => {
|
||||
renderInput();
|
||||
expect(editorState.focused).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ChatInput @ context wiring", () => {
|
||||
it("configures chat @ with current/recent issue/project context", () => {
|
||||
const contextItems = [
|
||||
|
||||
@@ -85,6 +85,11 @@ interface ChatInputProps {
|
||||
leftAdornment?: ReactNode;
|
||||
/** Chat @ suggestions: current/recent issue/project entries. */
|
||||
contextItems?: MentionItem[];
|
||||
/** Monotonic nonce bumped by the owner whenever the compose box should grab
|
||||
* keyboard focus — currently on "new chat" so the user can type right away.
|
||||
* 0 (the initial value) is inert, so a plain deep-link open never steals
|
||||
* focus; only an explicit bump does. */
|
||||
focusRequest?: number;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
@@ -100,6 +105,7 @@ export function ChatInput({
|
||||
agentName,
|
||||
leftAdornment,
|
||||
contextItems,
|
||||
focusRequest,
|
||||
}: ChatInputProps) {
|
||||
const { t } = useT("chat");
|
||||
const editorRef = useRef<ContentEditorRef>(null);
|
||||
@@ -168,6 +174,15 @@ export function ChatInput({
|
||||
// attachment never binds to the chat message.
|
||||
const uploadMapRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Grab keyboard focus when the owner bumps `focusRequest` (a new chat was
|
||||
// started) so the user can type immediately. The editor's `focus()` latches
|
||||
// through to `onCreate` when it isn't mounted yet, so this works even on the
|
||||
// first render of a freshly-mounted compose box. `0` is inert on purpose.
|
||||
useEffect(() => {
|
||||
if (!focusRequest) return;
|
||||
editorRef.current?.focus();
|
||||
}, [focusRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoreDraftRequest) {
|
||||
consumedRestoreIdRef.current = null;
|
||||
|
||||
@@ -220,6 +220,14 @@ export function ChatWindow() {
|
||||
const handleRestoreDraftConsumed = useCallback(() => {
|
||||
setRestoreDraftRequest(null);
|
||||
}, []);
|
||||
// Nonce handed to ChatInput to pull focus into the compose box when a new
|
||||
// chat starts (⊕ or switching agent). 0 is inert so opening the window on an
|
||||
// existing session never steals focus.
|
||||
const [focusRequest, setFocusRequest] = useState(0);
|
||||
const requestInputFocus = useCallback(
|
||||
() => setFocusRequest((n) => n + 1),
|
||||
[],
|
||||
);
|
||||
|
||||
// Legacy archived sessions (the old soft-archive feature was removed but
|
||||
// pre-existing rows with status='archived' may still exist) are excluded
|
||||
@@ -663,8 +671,9 @@ export function ChatWindow() {
|
||||
setSelectedAgentId(agent.id);
|
||||
// Reset session when switching agent
|
||||
setActiveSession(null);
|
||||
requestInputFocus();
|
||||
},
|
||||
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession],
|
||||
[activeAgent, selectedAgentId, activeSessionId, setSelectedAgentId, setActiveSession, requestInputFocus],
|
||||
);
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
@@ -673,7 +682,8 @@ export function ChatWindow() {
|
||||
previousPendingTask: pendingTaskId,
|
||||
});
|
||||
setActiveSession(null);
|
||||
}, [activeSessionId, pendingTaskId, setActiveSession]);
|
||||
requestInputFocus();
|
||||
}, [activeSessionId, pendingTaskId, setActiveSession, requestInputFocus]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(session: ChatSession) => {
|
||||
@@ -864,6 +874,7 @@ export function ChatWindow() {
|
||||
/>
|
||||
}
|
||||
contextItems={contextItems}
|
||||
focusRequest={focusRequest}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
@@ -241,6 +241,14 @@ export function useChatController(opts?: { isActive?: boolean }) {
|
||||
const handleRestoreDraftConsumed = useCallback(() => {
|
||||
setRestoreDraftRequest(null);
|
||||
}, []);
|
||||
// Nonce handed to ChatInput to pull focus into the compose box when a new
|
||||
// chat starts. Bumped by handleNewChat / handleStartNewChat only, so
|
||||
// selecting an existing chat or a deep link never steals focus.
|
||||
const [focusInputRequest, setFocusInputRequest] = useState(0);
|
||||
const requestInputFocus = useCallback(
|
||||
() => setFocusInputRequest((n) => n + 1),
|
||||
[],
|
||||
);
|
||||
|
||||
const currentSession = activeSessionId
|
||||
? sessions.find((s) => s.id === activeSessionId)
|
||||
@@ -575,7 +583,8 @@ export function useChatController(opts?: { isActive?: boolean }) {
|
||||
previousPendingTask: pendingTaskId,
|
||||
});
|
||||
setActiveSession(null);
|
||||
}, [activeSessionId, pendingTaskId, setActiveSession]);
|
||||
requestInputFocus();
|
||||
}, [activeSessionId, pendingTaskId, setActiveSession, requestInputFocus]);
|
||||
|
||||
// Start a fresh chat bound to a chosen agent. Unlike handleSelectAgent this
|
||||
// does not no-op when the agent is unchanged — "new chat" always clears the
|
||||
@@ -589,8 +598,9 @@ export function useChatController(opts?: { isActive?: boolean }) {
|
||||
});
|
||||
setSelectedAgentId(agent.id);
|
||||
setActiveSession(null);
|
||||
requestInputFocus();
|
||||
},
|
||||
[activeSessionId, setSelectedAgentId, setActiveSession],
|
||||
[activeSessionId, setSelectedAgentId, setActiveSession, requestInputFocus],
|
||||
);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
@@ -667,6 +677,8 @@ export function useChatController(opts?: { isActive?: boolean }) {
|
||||
// draft restore
|
||||
restoreDraftRequest,
|
||||
handleRestoreDraftConsumed,
|
||||
// compose-box focus nonce (bumped on new chat)
|
||||
focusInputRequest,
|
||||
// actions
|
||||
handleSend,
|
||||
handleStop,
|
||||
|
||||
@@ -305,6 +305,11 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const initialContent = defaultValue ? preprocessMarkdown(defaultValue) : "";
|
||||
// With `immediatelyRender: false` the Tiptap instance is created after
|
||||
// mount, so an imperative `focus()` fired on the same tick (e.g. chat
|
||||
// auto-focusing a brand-new conversation) would hit a null editor and no-op.
|
||||
// Latch the intent here and honor it in `onCreate` once the editor exists.
|
||||
const focusOnReadyRef = useRef(false);
|
||||
// Large markdown is parsed in chunks to dodge marked's O(n²) tokenizer (see
|
||||
// parseMarkdownChunked). Small docs stay on the single-parse fast path.
|
||||
const mountChunked = initialContent.length > MARKDOWN_CHUNK_THRESHOLD;
|
||||
@@ -337,6 +342,10 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
// repair it so the mounted editor has a real cursor in the list.
|
||||
repairEmptyListItems(ed);
|
||||
lastEmittedRef.current = normalizeEditorMarkdown(ed);
|
||||
if (focusOnReadyRef.current) {
|
||||
focusOnReadyRef.current = false;
|
||||
ed.commands.focus("end");
|
||||
}
|
||||
},
|
||||
content: mountChunked ? "" : initialContent,
|
||||
contentType: mountChunked
|
||||
@@ -519,7 +528,9 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
|
||||
editor?.commands.clearContent();
|
||||
},
|
||||
focus: () => {
|
||||
editor?.commands.focus();
|
||||
if (editor) editor.commands.focus();
|
||||
// Editor not mounted yet — defer the focus to `onCreate`.
|
||||
else focusOnReadyRef.current = true;
|
||||
},
|
||||
blur: () => {
|
||||
editor?.commands.blur();
|
||||
|
||||
Reference in New Issue
Block a user