From 4ad43df42d8e6f5c9dae962d4cf4cbad2aadf3de Mon Sep 17 00:00:00 2001 From: Seydi Charyyev Date: Tue, 1 Sep 2026 07:50:13 +0000 Subject: [PATCH] fix(desktop): keep unsent chat input across navigation (#11494) Signed-off-by: Seydi Charyyev Co-authored-by: Lifei Zhou --- ui/desktop/src/App.tsx | 14 ++- ui/desktop/src/components/ChatInput.tsx | 75 +++++++----- ui/desktop/src/components/Hub.test.tsx | 151 ++++++++++++++++++++++++ ui/desktop/src/components/Hub.tsx | 14 ++- 4 files changed, 219 insertions(+), 35 deletions(-) create mode 100644 ui/desktop/src/components/Hub.test.tsx diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index 763af9f36..f5b1d5213 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useRef } from 'react'; +import { useEffect, useState, useRef, type RefObject } from 'react'; import { IpcRendererEvent } from 'electron'; import { HashRouter, Routes, Route, useNavigate, useLocation, useSearchParams } from 'react-router'; import { importNostrSessionFromDeepLink } from './sessionLinks'; @@ -59,9 +59,9 @@ function PageViewTracker() { } // Route Components -const HubRouteWrapper = () => { +const HubRouteWrapper = ({ draftRef }: { draftRef: RefObject }) => { const setView = useNavigation(); - return ; + return ; }; export function resolveSessionInitialMessage( @@ -318,6 +318,12 @@ export function AppInner() { recipe: null, }); + // New Chat is the only chat that unmounts on navigation; the rest stay mounted in + // `ChatSessionsContainer` and keep their text in local state. Its unsent input lives + // here so it outlives that unmount, and in a ref rather than state because nothing + // above the outlet has to render on a keystroke. + const hubDraftRef = useRef(''); + const MAX_ACTIVE_SESSIONS = 10; const [activeSessions, setActiveSessions] = useState< @@ -641,7 +647,7 @@ export function AppInner() { } > - } /> + } /> ; droppedFiles?: DroppedFile[]; onFilesProcessed?: () => void; setView: (view: View) => void; @@ -204,6 +210,7 @@ export default function ChatInput({ queueProcessingBlocked = false, commandHistory = [], initialValue = '', + draftRef, droppedFiles = [], onFilesProcessed, setView, @@ -235,6 +242,19 @@ export default function ChatInput({ const [pastedImages, setPastedImages] = useState([]); const [isFilePickerOpen, setIsFilePickerOpen] = useState(false); + // Every path that puts text in the input goes through here, so the draft cannot + // miss one: typing, dictation, link paste, history, file and mention insertion. + const applyInputValue = useCallback( + (next: string) => { + setDisplayValue(next); + setValue(next); + if (draftRef) { + draftRef.current = next; + } + }, + [draftRef] + ); + // Derived state - chatState != Idle means we're in some form of loading state const isLoading = chatState !== ChatState.Idle; const isLoadingRef = useRef(isLoading); @@ -492,8 +512,7 @@ export default function ChatInput({ ? `${displayValue.trim()} ${cleanedText}` : displayValue.trim() || cleanedText; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(newValue); if (shouldAutoSubmit && newValue.trim()) { trackVoiceDictation('auto_submit'); @@ -518,13 +537,18 @@ export default function ChatInput({ const timeoutRefsRef = useRef>>(new Set()); useEffect(() => { - setValue(initialValue); - setDisplayValue(initialValue); + // The draft is restored here rather than through `initialValue`, because this + // effect also runs on mount and would overwrite a value seeded into `useState`. + // It stays a ref for the same reason: a prop that changed on every keystroke + // would re-run this effect and reset the state it clears below. + const restored = draftRef?.current || initialValue; + setValue(restored); + setDisplayValue(restored); setPastedImages([]); setHistoryIndex(-1); setIsInGlobalHistory(false); setHasUserTyped(false); - }, [initialValue]); + }, [initialValue, draftRef]); // Handle recipe prompt updates useEffect(() => { @@ -707,11 +731,6 @@ export default function ChatInput({ const maxHeight = 10 * 24; - // Immediate function to update actual value - no debounce for better responsiveness - const updateValue = React.useCallback((value: string) => { - setValue(value); - }, []); - const minTextareaHeight = 38; const debouncedAutosize = useMemo( @@ -749,8 +768,7 @@ export default function ChatInput({ const val = evt.target.value; const cursorPosition = evt.target.selectionStart; - setDisplayValue(val); - updateValue(val); + applyInputValue(val); setHasUserTyped(true); checkForMentionOrSlash(val, cursorPosition, evt.target); }; @@ -846,13 +864,22 @@ export default function ChatInput({ setDisplayValue(''); setValue(''); setPastedImages([]); + if (draftRef) { + draftRef.current = ''; + } if (onFilesProcessed && droppedFiles.length > 0) { onFilesProcessed(); } if (localDroppedFiles.length > 0) { setLocalDroppedFiles([]); } - }, [droppedFiles.length, localDroppedFiles.length, onFilesProcessed, setLocalDroppedFiles]); + }, [ + draftRef, + droppedFiles.length, + localDroppedFiles.length, + onFilesProcessed, + setLocalDroppedFiles, + ]); const handlePaste = async (evt: React.ClipboardEvent) => { if (isRecording) return; @@ -876,8 +903,7 @@ export default function ChatInput({ const newValue = displayValue.substring(0, start) + markdown + displayValue.substring(end); const cursorPos = start + markdown.length; - setDisplayValue(newValue); - updateValue(newValue); + applyInputValue(newValue); setHasUserTyped(true); checkForMentionOrSlash(newValue, cursorPos, textarea); requestAnimationFrame(() => { @@ -1040,13 +1066,7 @@ export default function ChatInput({ // Update display if we have a new value if (newIndex !== historyIndex) { setHistoryIndex(newIndex); - if (newIndex === -1) { - setDisplayValue(savedInput || ''); - setValue(savedInput || ''); - } else { - setDisplayValue(newValue || ''); - setValue(newValue || ''); - } + applyInputValue((newIndex === -1 ? savedInput : newValue) || ''); // Reset hasUserTyped when we populate from history setHasUserTyped(false); } @@ -1203,9 +1223,7 @@ export default function ChatInput({ } if (evt.altKey) { - const newValue = displayValue + '\n'; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(displayValue + '\n'); return; } @@ -1305,9 +1323,7 @@ export default function ChatInput({ } else { trackFileAttached('file'); const path = window.electron.getPathForFile(file); - const newValue = displayValue.trim() ? `${displayValue.trim()} ${path}` : path; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(displayValue.trim() ? `${displayValue.trim()} ${path}` : path); } textAreaRef.current?.focus(); @@ -1325,8 +1341,7 @@ export default function ChatInput({ ); const newValue = `${beforeMention}${itemText}${afterMention}`; - setDisplayValue(newValue); - setValue(newValue); + applyInputValue(newValue); setMentionPopover((prev) => ({ ...prev, isOpen: false })); textAreaRef.current?.focus(); diff --git a/ui/desktop/src/components/Hub.test.tsx b/ui/desktop/src/components/Hub.test.tsx new file mode 100644 index 000000000..a4aa6832a --- /dev/null +++ b/ui/desktop/src/components/Hub.test.tsx @@ -0,0 +1,151 @@ +/** + * @vitest-environment jsdom + */ +import { act, render } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import Hub from './Hub'; +import { IntlTestWrapper } from '../i18n/test-utils'; +import { createSession } from '../sessions'; +import { UserInput } from '../types/message'; + +type ChatInputCapture = { + draftRef?: { current: string }; + handleSubmit: (input: UserInput) => void; +}; + +type Session = Awaited>; + +const captured = vi.hoisted(() => ({ chatInput: null as ChatInputCapture | null })); + +vi.mock('./ChatInput', () => ({ + default: (props: ChatInputCapture) => { + captured.chatInput = props; + return
; + }, +})); + +vi.mock('./LoadingGoose', () => ({ default: () =>
})); + +vi.mock('./ConfigContext', () => ({ + useConfig: () => ({ extensionsList: [] }), +})); + +vi.mock('../sessions', () => ({ createSession: vi.fn() })); + +vi.mock('../utils/workingDir', () => ({ + getInitialWorkingDir: () => '/tmp/goose', + getEffectiveWorkingDir: () => Promise.resolve('/tmp/goose'), +})); + +vi.mock('../utils/nextChatExtensions', () => ({ + createNextChatExtensionDraft: () => ({}), + selectNextChatExtensions: () => [], +})); + +vi.mock('../acp/errors', () => ({ formatAcpError: (error: unknown) => String(error) })); + +vi.mock('../toasts', () => ({ toastError: vi.fn() })); + +const DRAFT = 'a half-written thought'; +const TYPED_WHILE_STARTING = 'and one more thought'; + +/** Holds session creation open, so the test can edit the draft while it is pending. */ +function pendingSession() { + const settle: { started?: () => void; failed?: () => void } = {}; + vi.mocked(createSession).mockImplementation( + () => + new Promise((resolve, reject) => { + settle.started = () => resolve({ id: 'session-1' } as Session); + settle.failed = () => reject(new Error('no agent')); + }) + ); + return settle; +} + +function renderHub(draftRef: { current: string }) { + return render( + + + + ); +} + +async function submit() { + await act(async () => { + captured.chatInput?.handleSubmit({ msg: DRAFT, images: [] }); + }); +} + +describe('Hub', () => { + beforeEach(() => { + vi.clearAllMocks(); + captured.chatInput = null; + }); + + it('hands the draft to the input', () => { + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + expect(captured.chatInput?.draftRef).toBe(draftRef); + }); + + it('drops the draft once the chat starts', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + await act(async () => session.started?.()); + + expect(draftRef.current).toBe(''); + }); + + it('keeps the draft when the chat fails to start', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + await act(async () => session.failed?.()); + + expect(draftRef.current).toBe(DRAFT); + }); + + // The input stays editable while the session is being created, so what is in the + // draft when creation ends is not necessarily what was submitted. + it('keeps text typed while the chat was starting', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + draftRef.current = TYPED_WHILE_STARTING; + await act(async () => session.started?.()); + + expect(draftRef.current).toBe(TYPED_WHILE_STARTING); + }); + + it('keeps text typed while a failing chat was starting', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + draftRef.current = TYPED_WHILE_STARTING; + await act(async () => session.failed?.()); + + expect(draftRef.current).toBe(TYPED_WHILE_STARTING); + }); + + it('leaves the draft empty when the input was cleared while the chat was starting', async () => { + const session = pendingSession(); + const draftRef = { current: DRAFT }; + renderHub(draftRef); + + await submit(); + draftRef.current = ''; + await act(async () => session.failed?.()); + + expect(draftRef.current).toBe(''); + }); +}); diff --git a/ui/desktop/src/components/Hub.tsx b/ui/desktop/src/components/Hub.tsx index 91e7da555..66fccf845 100644 --- a/ui/desktop/src/components/Hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -7,7 +7,7 @@ * lives there. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from 'react'; import { defineMessages, useIntl } from '../i18n'; import { AppEvents } from '../constants/events'; import ChatInput from './ChatInput'; @@ -47,8 +47,11 @@ function useClock() { export default function Hub({ setView, + draftRef, }: { setView: (view: View, viewOptions?: ViewOptions) => void; + /** Unsent input of this screen, kept above the route outlet across the unmount. */ + draftRef: RefObject; }) { const intl = useIntl(); const { extensionsList } = useConfig(); @@ -104,6 +107,7 @@ export default function Hub({ const { msg: userMessage, images } = input; if (!(images.length > 0 || userMessage.trim()) || isCreatingSession) return; + const draftAtSubmit = draftRef.current; setIsCreatingSession(true); try { @@ -128,6 +132,13 @@ export default function Hub({ }) ); + // The draft is this screen's own, so it is dropped once the session exists. + // Comparing it against the value at submit leaves an edit made while the + // session was starting alone, including one that emptied the input. + if (draftRef.current === draftAtSubmit) { + draftRef.current = ''; + } + setView('pair', { disableAnimation: true, resumeSessionId: session.id, @@ -156,6 +167,7 @@ export default function Hub({ {}}