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:
+10
-4
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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={() => {}}
|
||||
|
||||
Reference in New Issue
Block a user