fix(desktop): keep unsent chat input across navigation (#11494)

Signed-off-by: Seydi Charyyev <seydi.charyev@gmail.com>
Co-authored-by: Lifei Zhou <lifei@squareup.com>
This commit is contained in:
Seydi Charyyev
2026-09-01 07:50:13 +00:00
committed by GitHub
parent c1646b8405
commit 4ad43df42d
4 changed files with 219 additions and 35 deletions
+10 -4
View File
@@ -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<string> }) => {
const setView = useNavigation();
return <Hub setView={setView} />;
return <Hub setView={setView} draftRef={draftRef} />;
};
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() {
</OnboardingGuard>
}
>
<Route index element={<HubRouteWrapper />} />
<Route index element={<HubRouteWrapper draftRef={hubDraftRef} />} />
<Route
path="pair"
element={
+45 -30
View File
@@ -168,6 +168,12 @@ interface ChatInputProps {
queueProcessingBlocked?: boolean;
commandHistory?: string[];
initialValue?: string;
/**
* Unsent input, held above the route outlet so it outlives the unmount.
* Only New Chat passes it: every other chat stays mounted in
* `ChatSessionsContainer` and keeps its text in local state.
*/
draftRef?: React.RefObject<string>;
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<PastedImage[]>([]);
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<Set<ReturnType<typeof setTimeout>>>(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<HTMLTextAreaElement>) => {
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();
+151
View File
@@ -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<ReturnType<typeof createSession>>;
const captured = vi.hoisted(() => ({ chatInput: null as ChatInputCapture | null }));
vi.mock('./ChatInput', () => ({
default: (props: ChatInputCapture) => {
captured.chatInput = props;
return <div data-testid="chat-input" />;
},
}));
vi.mock('./LoadingGoose', () => ({ default: () => <div /> }));
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<Session>((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(
<IntlTestWrapper>
<Hub setView={vi.fn()} draftRef={draftRef} />
</IntlTestWrapper>
);
}
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('');
});
});
+13 -1
View File
@@ -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<string>;
}) {
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({
<ChatInputCard>
<ChatInput
sessionId={null}
draftRef={draftRef}
handleSubmit={handleSubmit}
chatState={isCreatingSession ? ChatState.LoadingConversation : ChatState.Idle}
onStop={() => {}}