diff --git a/ui/desktop/package.json b/ui/desktop/package.json index 4eb409e1f9..e10e49b36a 100644 --- a/ui/desktop/package.json +++ b/ui/desktop/package.json @@ -30,8 +30,7 @@ "format": "prettier --write \"src/**/*.{ts,tsx,css,json}\"", "format:check": "prettier --check \"src/**/*.{ts,tsx,css,json}\"", "prepare": "cd ../.. && husky install", - "start-alpha-gui": "ALPHA=true npm run start-gui", - "start-alpha-server": "cd ../.. && just run-ui-alpha" + "start-alpha-gui": "ALPHA=true npm run start-gui" }, "devDependencies": { "@electron-forge/cli": "^7.5.0", diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 669eaef30e..0141b588d8 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -5,19 +5,20 @@ import Stop from './ui/Stop'; import { Attach, Send } from './icons'; import { debounce } from 'lodash'; import BottomMenu from './bottom_menu/BottomMenu'; +import { LocalMessageStorage } from '../utils/localMessageStorage'; -interface InputProps { +interface ChatInputProps { handleSubmit: (e: React.FormEvent) => void; isLoading?: boolean; onStop?: () => void; - commandHistory?: string[]; + commandHistory?: string[]; // Current chat's message history initialValue?: string; droppedFiles?: string[]; setView: (view: View) => void; numTokens?: number; } -export default function Input({ +export default function ChatInput({ handleSubmit, isLoading = false, onStop, @@ -26,23 +27,25 @@ export default function Input({ setView, numTokens, droppedFiles = [], -}: InputProps) { +}: ChatInputProps) { const [_value, setValue] = useState(initialValue); const [displayValue, setDisplayValue] = useState(initialValue); // For immediate visual feedback const [isFocused, setIsFocused] = useState(false); // Update internal value when initialValue changes useEffect(() => { - if (initialValue) { - setValue(initialValue); - setDisplayValue(initialValue); - } + setValue(initialValue); + setDisplayValue(initialValue); + // Reset history index when input is cleared + setHistoryIndex(-1); + setIsInGlobalHistory(false); }, [initialValue]); // State to track if the IME is composing (i.e., in the middle of Japanese IME input) const [isComposing, setIsComposing] = useState(false); const [historyIndex, setHistoryIndex] = useState(-1); const [savedInput, setSavedInput] = useState(''); + const [isInGlobalHistory, setIsInGlobalHistory] = useState(false); const textAreaRef = useRef(null); const [processedFilePaths, setProcessedFilePaths] = useState([]); @@ -56,17 +59,20 @@ export default function Input({ const maxHeight = 10 * 24; // If we have dropped files, add them to the input and update our state. - if (processedFilePaths !== droppedFiles) { - // Append file paths that aren't in displayValue. - let joinedPaths = - displayValue.trim() + - ' ' + - droppedFiles.filter((path) => !displayValue.includes(path)).join(' '); - setDisplayValue(joinedPaths); - setValue(joinedPaths); - textAreaRef.current?.focus(); - setProcessedFilePaths(droppedFiles); - } + useEffect(() => { + if (processedFilePaths !== droppedFiles && droppedFiles.length > 0) { + // Append file paths that aren't in displayValue. + const currentText = displayValue || ''; + const joinedPaths = currentText.trim() + ? `${currentText.trim()} ${droppedFiles.filter((path) => !currentText.includes(path)).join(' ')}` + : droppedFiles.join(' '); + + setDisplayValue(joinedPaths); + setValue(joinedPaths); + textAreaRef.current?.focus(); + setProcessedFilePaths(droppedFiles); + } + }, [droppedFiles, processedFilePaths, displayValue]); // Debounced function to update actual value const debouncedSetValue = useCallback((val: string) => { @@ -117,49 +123,77 @@ export default function Input({ }; const handleHistoryNavigation = (evt: React.KeyboardEvent) => { - evt.preventDefault(); + const isUp = evt.key === 'ArrowUp'; + const isDown = evt.key === 'ArrowDown'; - // Save current input if we're just starting to navigate history - if (historyIndex === -1) { - setSavedInput(displayValue); - } - - // Calculate new history index - let newIndex = historyIndex; - if (evt.key === 'ArrowUp') { - // Move backwards through history - if (historyIndex < commandHistory.length - 1) { - newIndex = historyIndex + 1; - } - } else { - // Move forwards through history - if (historyIndex > -1) { - newIndex = historyIndex - 1; - } - } - - if (newIndex === historyIndex) { + // Only handle up/down keys with Cmd/Ctrl modifier + if ((!isUp && !isDown) || !(evt.metaKey || evt.ctrlKey) || evt.altKey || evt.shiftKey) { return; } - // Update index and value - setHistoryIndex(newIndex); - if (newIndex === -1) { - // Restore saved input when going past the end of history - setDisplayValue(savedInput); - setValue(savedInput); + evt.preventDefault(); + + // Get global history once to avoid multiple calls + const globalHistory = LocalMessageStorage.getRecentMessages() || []; + + // Save current input if we're just starting to navigate history + if (historyIndex === -1) { + setSavedInput(displayValue || ''); + setIsInGlobalHistory(commandHistory.length === 0); + } + + // Determine which history we're using + const currentHistory = isInGlobalHistory ? globalHistory : commandHistory; + let newIndex = historyIndex; + let newValue = ''; + + // Handle navigation + if (isUp) { + // Moving up through history + if (newIndex < currentHistory.length - 1) { + // Still have items in current history + newIndex = historyIndex + 1; + newValue = currentHistory[newIndex]; + } else if (!isInGlobalHistory && globalHistory.length > 0) { + // Switch to global history + setIsInGlobalHistory(true); + newIndex = 0; + newValue = globalHistory[newIndex]; + } } else { - setDisplayValue(commandHistory[newIndex] || ''); - setValue(commandHistory[newIndex] || ''); + // Moving down through history + if (newIndex > 0) { + // Still have items in current history + newIndex = historyIndex - 1; + newValue = currentHistory[newIndex]; + } else if (isInGlobalHistory && commandHistory.length > 0) { + // Switch to chat history + setIsInGlobalHistory(false); + newIndex = commandHistory.length - 1; + newValue = commandHistory[newIndex]; + } else { + // Return to original input + newIndex = -1; + newValue = savedInput; + } + } + + // 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 || ''); + } } }; const handleKeyDown = (evt: React.KeyboardEvent) => { - // Handle command history navigation - if ((evt.metaKey || evt.ctrlKey) && (evt.key === 'ArrowUp' || evt.key === 'ArrowDown')) { - handleHistoryNavigation(evt); - return; - } + // Handle history navigation first + handleHistoryNavigation(evt); if (evt.key === 'Enter') { // should not trigger submit on Enter if it's composing (IME input in progress) or shift/alt(option) is pressed @@ -180,11 +214,15 @@ export default function Input({ // Only submit if not loading and has content if (!isLoading && displayValue.trim()) { + // Always add to global chat storage before submitting + LocalMessageStorage.addMessage(displayValue); + handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); setDisplayValue(''); setValue(''); setHistoryIndex(-1); setSavedInput(''); + setIsInGlobalHistory(false); } } }; @@ -192,11 +230,15 @@ export default function Input({ const onFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (displayValue.trim() && !isLoading) { + // Always add to global chat storage before submitting + LocalMessageStorage.addMessage(displayValue); + handleSubmit(new CustomEvent('submit', { detail: { value: displayValue } })); setDisplayValue(''); setValue(''); setHistoryIndex(-1); setSavedInput(''); + setIsInGlobalHistory(false); } }; @@ -239,7 +281,7 @@ export default function Input({ maxHeight: `${maxHeight}px`, overflowY: 'auto', }} - className="w-full pl-4 pr-[68px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard" + className="w-full pl-4 pr-[68px] outline-none border-none focus:ring-0 bg-transparent pt-3 pb-1.5 text-sm resize-none text-textStandard placeholder:text-textPlaceholder placeholder:opacity-50" /> {isLoading ? ( diff --git a/ui/desktop/src/components/ChatView.tsx b/ui/desktop/src/components/ChatView.tsx index f930c2ab7b..6ae0ffbee9 100644 --- a/ui/desktop/src/components/ChatView.tsx +++ b/ui/desktop/src/components/ChatView.tsx @@ -1,7 +1,8 @@ -import React, { useEffect, useRef, useState, useMemo } from 'react'; +import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react'; import { getApiUrl } from '../config'; import FlappyGoose from './FlappyGoose'; import GooseMessage from './GooseMessage'; +import ChatInput from './ChatInput'; import { type View, ViewOptions } from '../App'; import LoadingGoose from './LoadingGoose'; import MoreMenuLayout from './more_menu/MoreMenuLayout'; @@ -23,6 +24,7 @@ import { useChatContextManager, } from './context_management/ContextManager'; import { ContextLengthExceededHandler } from './context_management/ContextLengthExceededHandler'; +import { LocalMessageStorage } from '../utils/localMessageStorage'; import { Message, createUserMessage, @@ -31,14 +33,12 @@ import { ToolRequestMessageContent, ToolResponseMessageContent, ToolConfirmationRequestMessageContent, + getTextContent, } from '../types/message'; -import ChatInput from './ChatInput'; export interface ChatType { id: string; title: string; - // messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests - // anything before this index should not render any buttons, but anything after should messageHistoryIndex: number; messages: Message[]; } @@ -88,8 +88,6 @@ function ChatContent({ setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; }) { - // Disabled askAi calls to save costs - // const [messageMetadata, setMessageMetadata] = useState>({}); const [hasMessages, setHasMessages] = useState(false); const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); const [showGame, setShowGame] = useState(false); @@ -121,9 +119,19 @@ function ChatContent({ // Get recipeConfig directly from appConfig const recipeConfig = window.appConfig.get('recipeConfig') as Recipe | null; + // Store message in global history when it's added + const storeMessageInHistory = useCallback((message: Message) => { + if (isUserMessage(message)) { + const text = getTextContent(message); + if (text) { + LocalMessageStorage.addMessage(text); + } + } + }, []); + const { messages, - append, + append: originalAppend, stop, isLoading, error, @@ -146,11 +154,6 @@ function ChatContent({ } }, 300); - // Disabled askAi calls to save costs - // const messageText = getTextContent(message); - // const fetchResponses = await askAi(messageText); - // setMessageMetadata((prev) => ({ ...prev, [message.id || '']: fetchResponses })); - const timeSinceLastInteraction = Date.now() - lastInteractionTime; window.electron.logInfo('last interaction:' + lastInteractionTime); if (timeSinceLastInteraction > 60000) { @@ -163,6 +166,17 @@ function ChatContent({ }, }); + // Wrap append to store messages in global history + const append = useCallback( + (messageOrString: Message | string) => { + const message = + typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString; + storeMessageInHistory(message); + return originalAppend(message); + }, + [originalAppend, storeMessageInHistory] + ); + // for CLE events -- create a new session id for the next set of messages useEffect(() => { // If we're in a continuation session, update the chat ID @@ -248,8 +262,6 @@ function ChatContent({ window.removeEventListener('make-agent-from-chat', handleMakeAgent); }; }, [messages]); - // do we need append here? - // }, [append, chat.messages]); // Update chat messages when they change and save to sessionStorage useEffect(() => { @@ -495,7 +507,7 @@ function ChatContent({ )} {messages.length === 0 ? ( append(createUserMessage(text))} + append={append} activities={Array.isArray(recipeConfig?.activities) ? recipeConfig.activities : null} title={recipeConfig?.title} /> @@ -526,7 +538,7 @@ function ChatContent({ messageHistoryIndex={chat?.messageHistoryIndex} message={message} messages={messages} - append={(text) => append(createUserMessage(text))} + append={append} appendMessage={(newMessage) => { const updatedMessages = [...messages, newMessage]; setMessages(updatedMessages); diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 3e25097c44..de418306f7 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -17,6 +17,8 @@ import ToolCallConfirmation from './ToolCallConfirmation'; import MessageCopyLink from './MessageCopyLink'; interface GooseMessageProps { + // messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests + // anything before this index should not render any buttons, but anything after should messageHistoryIndex: number; message: Message; messages: Message[]; diff --git a/ui/desktop/src/utils/localMessageStorage.ts b/ui/desktop/src/utils/localMessageStorage.ts new file mode 100644 index 0000000000..98de233791 --- /dev/null +++ b/ui/desktop/src/utils/localMessageStorage.ts @@ -0,0 +1,76 @@ +interface StoredMessage { + content: string; + timestamp: number; +} + +const STORAGE_KEY = 'goose-chat-history'; +const MAX_MESSAGES = 500; +const EXPIRY_DAYS = 30; + +export class LocalMessageStorage { + private static getStoredMessages(): StoredMessage[] { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (!stored) return []; + + const messages = JSON.parse(stored) as StoredMessage[]; + const now = Date.now(); + const expiryTime = now - EXPIRY_DAYS * 24 * 60 * 60 * 1000; + + // Filter out expired messages and limit to max count + const validMessages = messages + .filter((msg) => msg.timestamp > expiryTime) + .slice(-MAX_MESSAGES); + + // If we filtered any messages, update storage + if (validMessages.length !== messages.length) { + this.setStoredMessages(validMessages); + } + + return validMessages; + } catch (error) { + console.error('Error reading message history:', error); + return []; + } + } + + private static setStoredMessages(messages: StoredMessage[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); + } catch (error) { + console.error('Error saving message history:', error); + } + } + + static addMessage(content: string) { + if (!content.trim()) return; + + const messages = this.getStoredMessages(); + const now = Date.now(); + + // Don't add duplicate of last message + if (messages.length > 0 && messages[messages.length - 1].content === content) { + return; + } + + messages.push({ + content, + timestamp: now, + }); + + // Keep only the most recent MAX_MESSAGES + const validMessages = messages.slice(-MAX_MESSAGES); + + this.setStoredMessages(validMessages); + } + + static getRecentMessages(): string[] { + return this.getStoredMessages() + .map((msg) => msg.content) + .reverse(); // Most recent first + } + + static clearHistory() { + localStorage.removeItem(STORAGE_KEY); + } +}