From d54a06afda27adc6118c5be38d586be8f6de5fa8 Mon Sep 17 00:00:00 2001 From: Zane <75694352+zanesq@users.noreply.github.com> Date: Fri, 21 Nov 2025 14:10:34 -0700 Subject: [PATCH] Next camp refactor live (#5706) --- crates/goose-server/src/openapi.rs | 4 + crates/goose-server/src/routes/session.rs | 84 +++ crates/goose/src/agents/agent.rs | 2 - crates/goose/src/agents/tool_execution.rs | 14 +- crates/goose/src/session/session_manager.rs | 51 ++ ui/desktop/openapi.json | 91 +++ ui/desktop/src/App.tsx | 140 ++-- ui/desktop/src/api/sdk.gen.ts | 13 +- ui/desktop/src/api/types.gen.ts | 51 ++ ui/desktop/src/components/BaseChat.tsx | 632 ++++++++-------- ui/desktop/src/components/BaseChat2.tsx | 350 --------- ui/desktop/src/components/ChatInput.tsx | 16 +- ui/desktop/src/components/GooseMessage.tsx | 31 +- .../GroupedExtensionLoadingToast.tsx | 2 +- .../src/components/{hub.tsx => Hub.tsx} | 6 +- .../src/components/{Pair2.tsx => Pair.tsx} | 9 +- .../src/components/ProgressiveMessageList.tsx | 6 +- .../src/components/ToolCallWithResponse.tsx | 26 +- ui/desktop/src/components/UserMessage.tsx | 58 +- ui/desktop/src/components/pair.tsx | 182 ----- .../recipes/CreateEditRecipeModal.tsx | 1 - .../recipes/CreateRecipeFromSessionModal.tsx | 1 - .../src/components/recipes/RecipesView.tsx | 73 +- .../sessions/SessionHistoryView.tsx | 5 - .../components/sessions/SessionListView.tsx | 29 + .../src/components/sessions/SessionsView.tsx | 12 +- ui/desktop/src/hooks/useChatEngine.test.ts | 167 ----- ui/desktop/src/hooks/useChatEngine.ts | 473 ------------ ui/desktop/src/hooks/useChatStream.ts | 288 +++++--- ui/desktop/src/hooks/useMessageStream.ts | 672 ------------------ ui/desktop/src/main.ts | 104 ++- ui/desktop/src/preload.ts | 9 +- ui/desktop/src/sessions.ts | 80 ++- ui/desktop/src/toasts.tsx | 2 +- ui/desktop/src/types/message.ts | 22 +- 35 files changed, 1105 insertions(+), 2601 deletions(-) delete mode 100644 ui/desktop/src/components/BaseChat2.tsx rename ui/desktop/src/components/{hub.tsx => Hub.tsx} (92%) rename ui/desktop/src/components/{Pair2.tsx => Pair.tsx} (80%) delete mode 100644 ui/desktop/src/components/pair.tsx delete mode 100644 ui/desktop/src/hooks/useChatEngine.test.ts delete mode 100644 ui/desktop/src/hooks/useChatEngine.ts delete mode 100644 ui/desktop/src/hooks/useMessageStream.ts diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 42b890d79c..8870be0a73 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -368,6 +368,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::export_session, super::routes::session::import_session, super::routes::session::update_session_user_recipe_values, + super::routes::session::edit_message, super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, super::routes::schedule::delete_schedule, @@ -414,6 +415,9 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::UpdateSessionNameRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, super::routes::session::UpdateSessionUserRecipeValuesResponse, + super::routes::session::EditType, + super::routes::session::EditMessageRequest, + super::routes::session::EditMessageResponse, Message, MessageContent, MessageMetadata, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index 67a5bf388d..39cc203970 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -49,6 +49,31 @@ pub struct ImportSessionRequest { json: String, } +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum EditType { + Fork, + Edit, +} + +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EditMessageRequest { + timestamp: i64, + #[serde(default = "default_edit_type")] + edit_type: EditType, +} + +fn default_edit_type() -> EditType { + EditType::Fork +} + +#[derive(Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EditMessageResponse { + session_id: String, +} + const MAX_NAME_LENGTH: usize = 200; #[utoipa::path( @@ -307,6 +332,64 @@ async fn import_session( Ok(Json(session)) } +#[utoipa::path( + post, + path = "/sessions/{session_id}/edit_message", + request_body = EditMessageRequest, + params( + ("session_id" = String, Path, description = "Unique identifier for the session") + ), + responses( + (status = 200, description = "Session prepared for editing - frontend should submit the edited message", body = EditMessageResponse), + (status = 400, description = "Bad request - Invalid message timestamp"), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 404, description = "Session or message not found"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn edit_message( + Path(session_id): Path, + Json(request): Json, +) -> Result, StatusCode> { + match request.edit_type { + EditType::Fork => { + let new_session = SessionManager::copy_session(&session_id, "(edited)".to_string()) + .await + .map_err(|e| { + tracing::error!("Failed to copy session: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + SessionManager::truncate_conversation(&new_session.id, request.timestamp) + .await + .map_err(|e| { + tracing::error!("Failed to truncate conversation: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(EditMessageResponse { + session_id: new_session.id, + })) + } + EditType::Edit => { + SessionManager::truncate_conversation(&session_id, request.timestamp) + .await + .map_err(|e| { + tracing::error!("Failed to truncate conversation: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(EditMessageResponse { + session_id: session_id.clone(), + })) + } + } +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) @@ -320,5 +403,6 @@ pub fn routes(state: Arc) -> Router { "/sessions/{session_id}/user_recipe_values", put(update_session_user_recipe_values), ) + .route("/sessions/{session_id}/edit_message", post(edit_message)) .with_state(state) } diff --git a/crates/goose/src/agents/agent.rs b/crates/goose/src/agents/agent.rs index 39e3c1ce87..4078f98419 100644 --- a/crates/goose/src/agents/agent.rs +++ b/crates/goose/src/agents/agent.rs @@ -805,9 +805,7 @@ impl Agent { } else { SessionManager::add_message(&session_config.id, &user_message).await?; } - let session = SessionManager::get_session(&session_config.id, true).await?; - let conversation = session .conversation .clone() diff --git a/crates/goose/src/agents/tool_execution.rs b/crates/goose/src/agents/tool_execution.rs index 55ebb8b5be..383b23e297 100644 --- a/crates/goose/src/agents/tool_execution.rs +++ b/crates/goose/src/agents/tool_execution.rs @@ -71,12 +71,14 @@ impl Agent { } }); - let confirmation = Message::user().with_tool_confirmation_request( - request.id.clone(), - tool_call.name.to_string().clone(), - tool_call.arguments.clone().unwrap_or_default(), - security_message, - ); + let confirmation = Message::assistant() + .with_tool_confirmation_request( + request.id.clone(), + tool_call.name.to_string().clone(), + tool_call.arguments.clone().unwrap_or_default(), + security_message, + ) + .user_only(); yield confirmation; let mut rx = self.confirmation_rx.lock().await; diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index faf9b4551e..7d63e665b4 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -307,6 +307,20 @@ impl SessionManager { Self::instance().await?.import_session(json).await } + pub async fn copy_session(session_id: &str, new_name: String) -> Result { + Self::instance() + .await? + .copy_session(session_id, new_name) + .await + } + + pub async fn truncate_conversation(session_id: &str, timestamp: i64) -> Result<()> { + Self::instance() + .await? + .truncate_conversation(session_id, timestamp) + .await + } + pub async fn maybe_update_name(id: &str, provider: Arc) -> Result<()> { let session = Self::get_session(id, true).await?; @@ -1214,6 +1228,43 @@ impl SessionStorage { self.get_session(&session.id, true).await } + async fn copy_session(&self, session_id: &str, new_name: String) -> Result { + let original_session = self.get_session(session_id, true).await?; + + let new_session = self + .create_session( + original_session.working_dir.clone(), + new_name, + original_session.session_type, + ) + .await?; + + let builder = SessionUpdateBuilder::new(new_session.id.clone()) + .extension_data(original_session.extension_data) + .schedule_id(original_session.schedule_id) + .recipe(original_session.recipe) + .user_recipe_values(original_session.user_recipe_values); + + self.apply_update(builder).await?; + + if let Some(conversation) = original_session.conversation { + self.replace_conversation(&new_session.id, &conversation) + .await?; + } + + self.get_session(&new_session.id, true).await + } + + async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> { + sqlx::query("DELETE FROM messages WHERE session_id = ? AND created_timestamp >= ?") + .bind(session_id) + .bind(timestamp) + .execute(&self.pool) + .await?; + + Ok(()) + } + async fn search_chat_history( &self, query: &str, diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 679a01b49d..1507b4019a 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -2021,6 +2021,64 @@ ] } }, + "/sessions/{session_id}/edit_message": { + "post": { + "tags": [ + "Session Management" + ], + "operationId": "edit_message", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditMessageRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session prepared for editing - frontend should submit the edited message", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditMessageResponse" + } + } + } + }, + "400": { + "description": "Bad request - Invalid message timestamp" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session or message not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/sessions/{session_id}/export": { "get": { "tags": [ @@ -2560,6 +2618,39 @@ } } }, + "EditMessageRequest": { + "type": "object", + "required": [ + "timestamp" + ], + "properties": { + "editType": { + "$ref": "#/components/schemas/EditType" + }, + "timestamp": { + "type": "integer", + "format": "int64" + } + } + }, + "EditMessageResponse": { + "type": "object", + "required": [ + "sessionId" + ], + "properties": { + "sessionId": { + "type": "string" + } + } + }, + "EditType": { + "type": "string", + "enum": [ + "fork", + "edit" + ] + }, "EmbeddedResource": { "type": "object", "required": [ diff --git a/ui/desktop/src/App.tsx b/ui/desktop/src/App.tsx index ba3fff7a01..08349f8408 100644 --- a/ui/desktop/src/App.tsx +++ b/ui/desktop/src/App.tsx @@ -16,10 +16,11 @@ import { ToastContainer } from 'react-toastify'; import { GoosehintsModal } from './components/GoosehintsModal'; import AnnouncementModal from './components/AnnouncementModal'; import ProviderGuard from './components/ProviderGuard'; +import { createSession } from './sessions'; import { ChatType } from './types/chat'; -import Hub from './components/hub'; -import Pair, { PairRouteState } from './components/pair'; +import Hub from './components/Hub'; +import Pair, { PairRouteState } from './components/Pair'; import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView'; import SessionsView from './components/sessions/SessionsView'; import SharedSessionView from './components/sessions/SharedSessionView'; @@ -37,25 +38,17 @@ import PermissionSettingsView from './components/settings/permission/PermissionS import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView'; import RecipesView from './components/recipes/RecipesView'; import { View, ViewOptions } from './utils/navigationUtils'; -import { - AgentState, - InitializationContext, - NoProviderOrModelError, - useAgent, -} from './hooks/useAgent'; +import { NoProviderOrModelError, useAgent } from './hooks/useAgent'; import { useNavigation } from './hooks/useNavigation'; -import Pair2 from './components/Pair2'; import { errorMessage } from './utils/conversionUtils'; // Route Components const HubRouteWrapper = ({ setIsGoosehintsModalOpen, isExtensionsLoading, - resetChat, }: { setIsGoosehintsModalOpen: (isOpen: boolean) => void; isExtensionsLoading: boolean; - resetChat: () => void; }) => { const setView = useNavigation(); @@ -64,7 +57,6 @@ const HubRouteWrapper = ({ setView={setView} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} isExtensionsLoading={isExtensionsLoading} - resetChat={resetChat} /> ); }; @@ -73,30 +65,30 @@ const PairRouteWrapper = ({ chat, setChat, setIsGoosehintsModalOpen, - setAgentWaitingMessage, - setFatalError, - agentState, - loadCurrentChat, activeSessionId, setActiveSessionId, }: { chat: ChatType; setChat: (chat: ChatType) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; - setAgentWaitingMessage: (msg: string | null) => void; - setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; - agentState: AgentState; - loadCurrentChat: (context: InitializationContext) => Promise; activeSessionId: string | null; setActiveSessionId: (id: string | null) => void; }) => { const location = useLocation(); - const setView = useNavigation(); const routeState = (location.state as PairRouteState) || (window.history.state as PairRouteState) || {}; const [searchParams, setSearchParams] = useSearchParams(); - const initialMessage = routeState.initialMessage; + + // Capture initialMessage in local state to survive route state being cleared by setSearchParams + const [capturedInitialMessage, setCapturedInitialMessage] = useState( + undefined + ); + const [lastSessionId, setLastSessionId] = useState(undefined); + const [isCreatingSession, setIsCreatingSession] = useState(false); + const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined; + const recipeId = searchParams.get('recipeId') ?? undefined; + const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined; // Determine which session ID to use: // 1. From route state (when navigating from Hub with a new session) @@ -106,9 +98,71 @@ const PairRouteWrapper = ({ const sessionId = routeState.resumeSessionId || resumeSessionId || activeSessionId || chat.sessionId; + // Use route state if available, otherwise use captured state + const initialMessage = routeState.initialMessage || capturedInitialMessage; + + useEffect(() => { + if (routeState.initialMessage) { + setCapturedInitialMessage(routeState.initialMessage); + } + }, [routeState.initialMessage]); + + useEffect(() => { + // Create a new session if we have an initialMessage, recipeId, or recipeDeeplink from config but no sessionId + if ( + (initialMessage || recipeId || recipeDeeplinkFromConfig) && + !sessionId && + !isCreatingSession + ) { + console.log( + '[PairRouteWrapper] Creating new session for initialMessage, recipeId, or recipeDeeplink from config' + ); + setIsCreatingSession(true); + + (async () => { + try { + const newSession = await createSession({ + recipeId, + recipeDeeplink: recipeDeeplinkFromConfig, + }); + + setSearchParams((prev) => { + prev.set('resumeSessionId', newSession.id); + // Remove recipeId from URL after session is created + prev.delete('recipeId'); + return prev; + }); + setActiveSessionId(newSession.id); + } catch (error) { + console.error('[PairRouteWrapper] Failed to create session:', error); + } finally { + setIsCreatingSession(false); + } + })(); + } + }, [ + initialMessage, + recipeId, + recipeDeeplinkFromConfig, + sessionId, + isCreatingSession, + setSearchParams, + setActiveSessionId, + ]); + + // Clear captured initialMessage when sessionId actually changes to a different session + useEffect(() => { + if (sessionId !== lastSessionId) { + setLastSessionId(sessionId); + if (!routeState.initialMessage) { + setCapturedInitialMessage(undefined); + } + } + }, [sessionId, lastSessionId, routeState.initialMessage]); + // Update URL with session ID when on /pair route (for refresh support) useEffect(() => { - if (process.env.ALPHA && sessionId && sessionId !== resumeSessionId) { + if (sessionId && sessionId !== resumeSessionId) { setSearchParams((prev) => { prev.set('resumeSessionId', sessionId); return prev; @@ -118,31 +172,19 @@ const PairRouteWrapper = ({ // Update active session state when session ID changes useEffect(() => { - if (process.env.ALPHA && sessionId && sessionId !== activeSessionId) { + if (sessionId && sessionId !== activeSessionId) { setActiveSessionId(sessionId); } }, [sessionId, activeSessionId, setActiveSessionId]); - return process.env.ALPHA ? ( - - ) : ( - ); }; @@ -323,9 +365,7 @@ export function AppInner() { const navigate = useNavigate(); const setView = useNavigation(); - const location = useLocation(); - const [_searchParams, setSearchParams] = useSearchParams(); const [chat, setChat] = useState({ sessionId: '', @@ -339,16 +379,7 @@ export function AppInner() { const [activeSessionId, setActiveSessionId] = useState(null); const { addExtension } = useConfig(); - const { agentState, loadCurrentChat, resetChat } = useAgent(); - const resetChatIfNecessary = useCallback(() => { - if (chat.messages.length > 0) { - setSearchParams((prev) => { - prev.delete('resumeSessionId'); - return prev; - }); - resetChat(); - } - }, [chat.messages.length, setSearchParams, resetChat]); + const { loadCurrentChat } = useAgent(); useEffect(() => { console.log('Sending reactReady signal to Electron'); @@ -382,7 +413,7 @@ export function AppInner() { } })(); } - }, [resetChat, loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]); + }, [loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]); useEffect(() => { const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => { @@ -632,7 +663,6 @@ export function AppInner() { } /> @@ -642,10 +672,6 @@ export function AppInner() { = Options2 & { /** @@ -558,6 +558,17 @@ export const getSession = (options: Option }); }; +export const editMessage = (options: Options) => { + return (options.client ?? client).post({ + url: '/sessions/{session_id}/edit_message', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const exportSession = (options: Options) => { return (options.client ?? client).get({ url: '/sessions/{session_id}/export', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index 6135347bf0..901e0c3589 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -123,6 +123,17 @@ export type DeleteRecipeRequest = { id: string; }; +export type EditMessageRequest = { + editType?: EditType; + timestamp: number; +}; + +export type EditMessageResponse = { + sessionId: string; +}; + +export type EditType = 'fork' | 'edit'; + export type EmbeddedResource = { _meta?: { [key: string]: unknown; @@ -2550,6 +2561,46 @@ export type GetSessionResponses = { export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses]; +export type EditMessageData = { + body: EditMessageRequest; + path: { + /** + * Unique identifier for the session + */ + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}/edit_message'; +}; + +export type EditMessageErrors = { + /** + * Bad request - Invalid message timestamp + */ + 400: unknown; + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Session or message not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type EditMessageResponses = { + /** + * Session prepared for editing - frontend should submit the edited message + */ + 200: EditMessageResponse; +}; + +export type EditMessageResponse2 = EditMessageResponses[keyof EditMessageResponses]; + export type ExportSessionData = { body?: never; path: { diff --git a/ui/desktop/src/components/BaseChat.tsx b/ui/desktop/src/components/BaseChat.tsx index fa96740f06..07d0e8e366 100644 --- a/ui/desktop/src/components/BaseChat.tsx +++ b/ui/desktop/src/components/BaseChat.tsx @@ -1,232 +1,189 @@ -/** - * BaseChat Component - * - * BaseChat is the foundational chat component that provides the core conversational interface - * for the Goose Desktop application. It serves as the shared base for both Hub and Pair components, - * offering a flexible and extensible chat experience. - * - * Key Responsibilities: - * - Manages the complete chat lifecycle (messages, input, submission, responses) - * - Handles file drag-and-drop functionality with preview generation - * - Integrates with multiple specialized hooks for chat engine, recipes, sessions, etc. - * - Provides context management and session summarization capabilities - * - Supports both user and assistant message rendering with tool call integration - * - Manages loading states, error handling, and retry functionality - * - Offers customization points through render props and configuration options - * - * Architecture: - * - Uses a provider pattern (ChatContextManagerProvider) for state management - * - Leverages composition through render props for flexible UI customization - * - Integrates with multiple custom hooks for separation of concerns: - * - useChatEngine: Core chat functionality and API integration - * - useRecipeManager: Recipe/agent configuration management - * - useFileDrop: Drag-and-drop file handling with previews - * - useCostTracking: Token usage and cost calculation - * - * Customization Points: - * - renderHeader(): Custom header content (used by Hub for insights/recipe controls) - * - renderBeforeMessages(): Content before message list (used by Hub for SessionInsights) - * - renderAfterMessages(): Content after message list - * - customChatInputProps: Props passed to ChatInput for specialized behavior - * - customMainLayoutProps: Props passed to MainPanelLayout - * - contentClassName: Custom CSS classes for the content area - * - * File Handling: - * - Supports drag-and-drop of files with visual feedback - * - Generates image previews for supported file types - * - Integrates dropped files with chat input for seamless attachment - * - Uses data-drop-zone="true" to designate safe drop areas - * - * The component is designed to be the single source of truth for chat functionality - * while remaining flexible enough to support different UI contexts (Hub vs Pair). - */ - -import React, { createContext, useContext, useEffect, useRef } from 'react'; -import { useLocation } from 'react-router-dom'; +import React, { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useLocation, useNavigate, useSearchParams } from 'react-router-dom'; import { SearchView } from './conversation/SearchView'; -import { RecipeHeader } from './RecipeHeader'; import LoadingGoose from './LoadingGoose'; -import { getThinkingMessage } from '../types/message'; -import RecipeActivities from './recipes/RecipeActivities'; import PopularChatTopics from './PopularChatTopics'; import ProgressiveMessageList from './ProgressiveMessageList'; -import { View, ViewOptions } from '../utils/navigationUtils'; import { MainPanelLayout } from './Layout/MainPanelLayout'; import ChatInput from './ChatInput'; import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; -import { RecipeWarningModal } from './ui/RecipeWarningModal'; -import ParameterInputModal from './ParameterInputModal'; -import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; -import { useChatEngine } from '../hooks/useChatEngine'; -import { useRecipeManager } from '../hooks/useRecipeManager'; import { useFileDrop } from '../hooks/useFileDrop'; -import { useCostTracking } from '../hooks/useCostTracking'; +import { Message } from '../api'; import { ChatState } from '../types/chatState'; import { ChatType } from '../types/chat'; +import { useIsMobile } from '../hooks/use-mobile'; +import { useSidebar } from './ui/sidebar'; +import { cn } from '../utils'; +import { useChatStream } from '../hooks/useChatStream'; +import { useNavigation } from '../hooks/useNavigation'; +import { RecipeHeader } from './RecipeHeader'; +import { RecipeWarningModal } from './ui/RecipeWarningModal'; +import { scanRecipe } from '../recipe'; +import { useCostTracking } from '../hooks/useCostTracking'; +import RecipeActivities from './recipes/RecipeActivities'; import { useToolCount } from './alerts/useToolCount'; -import { Message } from '../api'; +import { getThinkingMessage, getTextContent } from '../types/message'; +import ParameterInputModal from './ParameterInputModal'; +import { substituteParameters } from '../utils/providerUtils'; +import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal'; +import { toastSuccess } from '../toasts'; +import { Recipe } from '../recipe'; // Context for sharing current model info const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null); export const useCurrentModelInfo = () => useContext(CurrentModelContext); interface BaseChatProps { - chat: ChatType; setChat: (chat: ChatType) => void; - setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen?: (isOpen: boolean) => void; - onMessageStreamFinish?: () => void; onMessageSubmit?: (message: string) => void; renderHeader?: () => React.ReactNode; - renderBeforeMessages?: () => React.ReactNode; - renderAfterMessages?: () => React.ReactNode; customChatInputProps?: Record; customMainLayoutProps?: Record; contentClassName?: string; disableSearch?: boolean; showPopularTopics?: boolean; - suppressEmptyState?: boolean; - autoSubmit?: boolean; - loadingChat: boolean; + suppressEmptyState: boolean; + sessionId: string; + initialMessage?: string; } function BaseChatContent({ - chat, - setChat, - setView, setIsGoosehintsModalOpen, - onMessageStreamFinish, - onMessageSubmit, renderHeader, - renderBeforeMessages, - renderAfterMessages, customChatInputProps = {}, customMainLayoutProps = {}, - contentClassName = '', - disableSearch = false, - showPopularTopics = false, - suppressEmptyState = false, - autoSubmit = false, - loadingChat = false, + sessionId, + initialMessage, }: BaseChatProps) { const location = useLocation(); + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); const scrollRef = useRef(null); const disableAnimation = location.state?.disableAnimation || false; const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); - const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState(null); + const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); + const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); - // Use shared chat engine - const { - messages, - filteredMessages, - append, - chatState, - error, - setMessages, - input, - handleSubmit: engineHandleSubmit, - onStopGoose, - sessionTokenCount, - sessionInputTokens, - sessionOutputTokens, - localInputTokens, - localOutputTokens, - tokenState, - commandHistory, - toolCallNotifications, - sessionMetadata, - isUserMessage, - clearError, - onMessageUpdate, - } = useChatEngine({ - chat, - setChat, - onMessageStreamFinish: () => { - // Call the original callback if provided - onMessageStreamFinish?.(); - }, - onMessageSent: () => { - // Mark that user has started using the recipe - if (recipe) { - setHasStartedUsingRecipe(true); - } - }, - }); + const isMobile = useIsMobile(); + const { state: sidebarState } = useSidebar(); + const setView = useNavigation(); - // Use shared recipe manager - const { - recipe, - recipeId, - recipeParameterValues, - filteredParameters, - initialPrompt, - isParameterModalOpen, - setIsParameterModalOpen, - handleParameterSubmit, - handleAutoExecution, - isRecipeWarningModalOpen, - recipeAccepted, - handleRecipeAccept, - handleRecipeCancel, - hasSecurityWarnings, - isCreateRecipeModalOpen, - setIsCreateRecipeModalOpen, - handleRecipeCreated, - } = useRecipeManager(chat, location.state?.recipe); - - // Reset recipe usage tracking when recipe changes - useEffect(() => { - const previousTitle = currentRecipeTitle; - const newTitle = recipe?.title || null; - const hasRecipeChanged = newTitle !== currentRecipeTitle; - - if (hasRecipeChanged) { - setCurrentRecipeTitle(newTitle); - - const isSwitchingBetweenRecipes = previousTitle && newTitle; - const isInitialRecipeLoad = !previousTitle && newTitle && messages.length === 0; - const hasExistingConversation = newTitle && messages.length > 0; - - if (isSwitchingBetweenRecipes) { - console.log('Switching from recipe:', previousTitle, 'to:', newTitle); - setHasStartedUsingRecipe(false); - setMessages([]); - } else if (isInitialRecipeLoad) { - setHasStartedUsingRecipe(false); - } else if (hasExistingConversation) { - setHasStartedUsingRecipe(true); - } - } - }, [recipe?.title, currentRecipeTitle, messages.length, setMessages]); - - // Handle recipe auto-execution - useEffect(() => { - const isProcessingResponse = - chatState !== ChatState.Idle && chatState !== ChatState.WaitingForUserInput; - handleAutoExecution(append, isProcessingResponse, () => { - setHasStartedUsingRecipe(true); - }); - }, [handleAutoExecution, append, chatState]); + const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11'); // Use shared file drop const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); - // Use shared cost tracking - const { sessionCosts } = useCostTracking({ - sessionInputTokens, - sessionOutputTokens, - localInputTokens, - localOutputTokens, - session: sessionMetadata, + const onStreamFinish = useCallback(() => {}, []); + + const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false); + const hasAutoSubmittedRef = useRef(false); + + // Reset auto-submit flag when session changes + useEffect(() => { + hasAutoSubmittedRef.current = false; + }, [sessionId]); + + const { + session, + messages, + chatState, + handleSubmit, + stopStreaming, + sessionLoadError, + setRecipeUserParams, + tokenState, + notifications: toolCallNotifications, + onMessageUpdate, + } = useChatStream({ + sessionId, + onStreamFinish, }); + // Generate command history from user messages (most recent first) + const commandHistory = useMemo(() => { + return messages + .reduce((history, message) => { + if (message.role === 'user') { + const text = getTextContent(message).trim(); + if (text) { + history.push(text); + } + } + return history; + }, []) + .reverse(); + }, [messages]); + useEffect(() => { - window.electron.logInfo( - 'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2) - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + if (!session || hasAutoSubmittedRef.current) { + return; + } + + const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true'; + + if (initialMessage) { + // Submit the initial message (e.g., from fork) + hasAutoSubmittedRef.current = true; + handleSubmit(initialMessage); + } else if (shouldStartAgent) { + // Trigger agent to continue with existing conversation + hasAutoSubmittedRef.current = true; + handleSubmit(''); + } + }, [session, initialMessage, searchParams, handleSubmit]); + + const handleFormSubmit = (e: React.FormEvent) => { + const customEvent = e as unknown as CustomEvent; + const textValue = customEvent.detail?.value || ''; + + if (recipe && textValue.trim()) { + setHasStartedUsingRecipe(true); + } + handleSubmit(textValue); + }; + + const { sessionCosts } = useCostTracking({ + sessionInputTokens: session?.accumulated_input_tokens || 0, + sessionOutputTokens: session?.accumulated_output_tokens || 0, + localInputTokens: 0, + localOutputTokens: 0, + session, + }); + + const recipe = session?.recipe; + + useEffect(() => { + if (!recipe) return; + + (async () => { + const accepted = await window.electron.hasAcceptedRecipeBefore(recipe); + setHasNotAcceptedRecipe(!accepted); + + if (!accepted) { + const scanResult = await scanRecipe(recipe); + setHasRecipeSecurityWarnings(scanResult.has_security_warnings); + } + })(); + }, [recipe]); + + const handleRecipeAccept = async (accept: boolean) => { + if (recipe && accept) { + await window.electron.recordRecipeHash(recipe); + setHasNotAcceptedRecipe(false); + } else { + setView('chat'); + } + }; // Track if this is the initial render for session resuming const initialRenderRef = useRef(true); @@ -246,34 +203,7 @@ function BaseChatContent({ } }, [messages.length]); - // Handle submit - const handleSubmit = (e: React.FormEvent) => { - const customEvent = e as unknown as CustomEvent; - const combinedTextFromInput = customEvent.detail?.value || ''; - - // Mark that user has started using the recipe when they submit a message - if (recipe && combinedTextFromInput.trim()) { - setHasStartedUsingRecipe(true); - } - - // Call the callback if provided (for Hub to handle navigation) - if (onMessageSubmit && combinedTextFromInput.trim()) { - onMessageSubmit(combinedTextFromInput); - } - - engineHandleSubmit(combinedTextFromInput); - }; - - const toolCount = useToolCount(chat.sessionId); - - // Wrapper for append that tracks recipe usage - const appendWithTracking = (text: string | Message) => { - // Mark that user has started using the recipe when they use append - if (recipe) { - setHasStartedUsingRecipe(true); - } - append(text); - }; + const toolCount = useToolCount(sessionId); // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) useEffect(() => { @@ -290,6 +220,121 @@ function BaseChatContent({ return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); }, []); + useEffect(() => { + const handleMakeAgent = () => { + setIsCreateRecipeModalOpen(true); + }; + + window.addEventListener('make-agent-from-chat', handleMakeAgent); + return () => window.removeEventListener('make-agent-from-chat', handleMakeAgent); + }, []); + + useEffect(() => { + const handleSessionForked = (event: Event) => { + const customEvent = event as CustomEvent<{ + newSessionId: string; + shouldStartAgent?: boolean; + editedMessage?: string; + }>; + const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail; + + const params = new URLSearchParams(); + params.set('resumeSessionId', newSessionId); + if (shouldStartAgent) { + params.set('shouldStartAgent', 'true'); + } + + navigate(`/pair?${params.toString()}`, { + state: { + disableAnimation: true, + initialMessage: editedMessage, + }, + }); + }; + + window.addEventListener('session-forked', handleSessionForked); + + return () => { + window.removeEventListener('session-forked', handleSessionForked); + }; + }, [location.pathname, navigate]); + + const handleRecipeCreated = (recipe: Recipe) => { + toastSuccess({ + title: 'Recipe created successfully!', + msg: `"${recipe.title}" has been saved and is ready to use.`, + }); + }; + + const renderProgressiveMessageList = (chat: ChatType) => ( + <> + m.role === 'user'} + isStreamingMessage={chatState !== ChatState.Idle} + onRenderingComplete={handleRenderingComplete} + onMessageUpdate={onMessageUpdate} + /> + + ); + + const showPopularTopics = + messages.length === 0 && !initialMessage && chatState === ChatState.Idle; + + const chat: ChatType = { + messageHistoryIndex: 0, + messages, + recipe, + sessionId, + name: session?.name || 'No Session', + }; + + // Only use initialMessage for the prompt if it hasn't been submitted yet + // If we have a recipe prompt and user recipe values, substitute parameters + let recipePrompt = ''; + if (messages.length === 0 && recipe?.prompt) { + recipePrompt = session?.user_recipe_values + ? substituteParameters(recipe.prompt, session.user_recipe_values) + : recipe.prompt; + } + + const initialPrompt = + (initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt; + + if (sessionLoadError) { + return ( +
+ + {renderHeader && renderHeader()} +
+
+
+
+

Failed to Load Session

+

{sessionLoadError}

+
+ +
+
+
+
+
+ ); + } + return (
- {/* Recipe agent header - sticky at top of chat container */} {recipe?.title && (
)} - {/* Custom content before messages */} - {renderBeforeMessages && renderBeforeMessages()} - - {/* Recipe Activities - always show when recipe is active and accepted */} - {recipe && recipeAccepted && !suppressEmptyState && ( + {recipe && (
appendWithTracking(text)} + append={(text: string) => handleSubmit(text)} activities={Array.isArray(recipe.activities) ? recipe.activities : null} title={recipe.title} - parameterValues={recipeParameterValues || {}} + //parameterValues={recipeParameters || {}} />
)} {/* Messages or Popular Topics */} - { - loadingChat ? null : filteredMessages.length > 0 || - (recipe && recipeAccepted && hasStartedUsingRecipe) ? ( - <> - {disableSearch ? ( - // Render messages without SearchView wrapper when search is disabled - { - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); - }} - isUserMessage={isUserMessage} - isStreamingMessage={chatState !== ChatState.Idle} - onMessageUpdate={onMessageUpdate} - onRenderingComplete={handleRenderingComplete} - /> - ) : ( - // Render messages with SearchView wrapper when search is enabled - - { - const updatedMessages = [...messages, newMessage]; - setMessages(updatedMessages); - }} - isUserMessage={isUserMessage} - isStreamingMessage={chatState !== ChatState.Idle} - onMessageUpdate={onMessageUpdate} - onRenderingComplete={handleRenderingComplete} - /> - - )} + {messages.length > 0 || recipe ? ( + <> + {renderProgressiveMessageList(chat)} - {error && ( - <> -
-
- {error.message || 'Honk! Goose experienced an error while responding'} -
- - {/* Action button to retry last message */} -
-
{ - clearError(); - // Find the last user message - const lastUserMessage = messages.reduceRight( - (found, m) => found || (m.role === 'user' ? m : null), - null as Message | null - ); - if (lastUserMessage) { - await append(lastUserMessage); - } - }} - > - Retry Last Message -
-
-
- - )} - -
- - ) : !recipe && showPopularTopics ? ( - /* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */ - append(text)} /> - ) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */ - } - - {/* Custom content after messages */} - {renderAfterMessages && renderAfterMessages()} +
+ + ) : !recipe && showPopularTopics ? ( + handleSubmit(text)} /> + ) : null} - {/* Fixed loading indicator at bottom left of chat container */} - {(chatState !== ChatState.Idle || loadingChat) && ( + {chatState !== ChatState.Idle && (
0 + ? getThinkingMessage(messages[messages.length - 1]) + : undefined + } />
)} @@ -436,19 +404,19 @@ function BaseChatContent({ className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`} > setDroppedFiles([])} // Clear dropped files after processing @@ -457,40 +425,36 @@ function BaseChatContent({ sessionCosts={sessionCosts} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} recipe={recipe} - recipeId={recipeId} - recipeAccepted={recipeAccepted} + recipeAccepted={!hasNotAcceptedRecipe} initialPrompt={initialPrompt} toolCount={toolCount || 0} - autoSubmit={autoSubmit} - append={append} {...customChatInputProps} />
- {/* Recipe Warning Modal */} - - - {/* Recipe Parameter Modal */} - {isParameterModalOpen && filteredParameters.length > 0 && ( - setIsParameterModalOpen(false)} + {recipe && ( + handleRecipeAccept(true)} + onCancel={() => handleRecipeAccept(false)} + recipeDetails={{ + title: recipe.title, + description: recipe.description, + instructions: recipe.instructions || undefined, + }} + hasSecurityWarnings={hasRecipeSecurityWarnings} + /> + )} + + {recipe?.parameters && recipe.parameters.length > 0 && !session?.user_recipe_values && ( + setView('chat')} /> )} - {/* Create Recipe from Session Modal */} setIsCreateRecipeModalOpen(false)} diff --git a/ui/desktop/src/components/BaseChat2.tsx b/ui/desktop/src/components/BaseChat2.tsx deleted file mode 100644 index 4653eea6c0..0000000000 --- a/ui/desktop/src/components/BaseChat2.tsx +++ /dev/null @@ -1,350 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useLocation } from 'react-router-dom'; -import { SearchView } from './conversation/SearchView'; -import LoadingGoose from './LoadingGoose'; -import PopularChatTopics from './PopularChatTopics'; -import ProgressiveMessageList from './ProgressiveMessageList'; -import { MainPanelLayout } from './Layout/MainPanelLayout'; -import ChatInput from './ChatInput'; -import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area'; -import { useFileDrop } from '../hooks/useFileDrop'; -import { Message } from '../api'; -import { ChatState } from '../types/chatState'; -import { ChatType } from '../types/chat'; -import { useIsMobile } from '../hooks/use-mobile'; -import { useSidebar } from './ui/sidebar'; -import { cn } from '../utils'; -import { useChatStream } from '../hooks/useChatStream'; -import { useNavigation } from '../hooks/useNavigation'; -import { RecipeHeader } from './RecipeHeader'; -import { RecipeWarningModal } from './ui/RecipeWarningModal'; -import { scanRecipe } from '../recipe'; -import { useCostTracking } from '../hooks/useCostTracking'; -import RecipeActivities from './recipes/RecipeActivities'; -import { useToolCount } from './alerts/useToolCount'; -import { getThinkingMessage } from '../types/message'; -import ParameterInputModal from './ParameterInputModal'; - -interface BaseChatProps { - setChat: (chat: ChatType) => void; - setIsGoosehintsModalOpen?: (isOpen: boolean) => void; - onMessageSubmit?: (message: string) => void; - renderHeader?: () => React.ReactNode; - customChatInputProps?: Record; - customMainLayoutProps?: Record; - contentClassName?: string; - disableSearch?: boolean; - showPopularTopics?: boolean; - suppressEmptyState: boolean; - autoSubmit?: boolean; - sessionId: string; - initialMessage?: string; -} - -function BaseChatContent({ - setIsGoosehintsModalOpen, - renderHeader, - customChatInputProps = {}, - customMainLayoutProps = {}, - sessionId, - initialMessage, - autoSubmit = false, -}: BaseChatProps) { - const location = useLocation(); - const scrollRef = useRef(null); - - const disableAnimation = location.state?.disableAnimation || false; - const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false); - const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState(); - const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false); - - const isMobile = useIsMobile(); - const { state: sidebarState } = useSidebar(); - const setView = useNavigation(); - - const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11'); - - // Use shared file drop - const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop(); - - const onStreamFinish = useCallback(() => {}, []); - - const { - session, - messages, - chatState, - handleSubmit, - stopStreaming, - sessionLoadError, - setRecipeUserParams, - tokenState, - } = useChatStream({ - sessionId, - onStreamFinish, - initialMessage, - }); - - const handleFormSubmit = (e: React.FormEvent) => { - const customEvent = e as unknown as CustomEvent; - const textValue = customEvent.detail?.value || ''; - - if (recipe && textValue.trim()) { - setHasStartedUsingRecipe(true); - } - handleSubmit(textValue); - }; - - const { sessionCosts } = useCostTracking({ - sessionInputTokens: session?.accumulated_input_tokens || 0, - sessionOutputTokens: session?.accumulated_output_tokens || 0, - localInputTokens: 0, - localOutputTokens: 0, - session, - }); - - const recipe = session?.recipe; - - useEffect(() => { - if (!recipe) return; - - (async () => { - const accepted = await window.electron.hasAcceptedRecipeBefore(recipe); - setHasNotAcceptedRecipe(!accepted); - - if (!accepted) { - const scanResult = await scanRecipe(recipe); - setHasRecipeSecurityWarnings(scanResult.has_security_warnings); - } - })(); - }, [recipe]); - - const handleRecipeAccept = async (accept: boolean) => { - if (recipe && accept) { - await window.electron.recordRecipeHash(recipe); - setHasNotAcceptedRecipe(false); - } else { - setView('chat'); - } - }; - - // Track if this is the initial render for session resuming - const initialRenderRef = useRef(true); - - // Auto-scroll when messages are loaded (for session resuming) - const handleRenderingComplete = React.useCallback(() => { - // Only force scroll on the very first render - if (initialRenderRef.current && messages.length > 0) { - initialRenderRef.current = false; - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - } else if (scrollRef.current?.isFollowing) { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - } - }, [messages.length]); - - const toolCount = useToolCount(sessionId); - - // Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions) - useEffect(() => { - const handleGlobalScrollRequest = () => { - // Add a small delay to ensure content has been rendered - setTimeout(() => { - if (scrollRef.current?.scrollToBottom) { - scrollRef.current.scrollToBottom(); - } - }, 200); - }; - - window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); - return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest); - }, []); - - const renderProgressiveMessageList = (chat: ChatType) => ( - <> - { - // const updatedMessages = [...messages, newMessage]; - // setMessages(updatedMessages); - // }} - isUserMessage={(m: Message) => m.role === 'user'} - isStreamingMessage={chatState !== ChatState.Idle} - // onMessageUpdate={onMessageUpdate} - onRenderingComplete={handleRenderingComplete} - /> - - ); - - const showPopularTopics = - messages.length === 0 && !initialMessage && chatState === ChatState.Idle; - - const chat: ChatType = { - messageHistoryIndex: 0, - messages, - recipe, - sessionId, - name: session?.name || 'No Session', - }; - - const initialPrompt = - initialMessage || (messages.length == 0 && recipe?.prompt ? recipe.prompt : ''); - const shouldAutoSubmit = autoSubmit || !!initialMessage; - - return ( -
-

Warning: BaseChat2!

- - {/* Custom header */} - {renderHeader && renderHeader()} - - {/* Chat container with sticky recipe header */} -
- - {recipe?.title && ( -
- -
- )} - - {recipe && ( -
- handleSubmit(text)} - activities={Array.isArray(recipe.activities) ? recipe.activities : null} - title={recipe.title} - //parameterValues={recipeParameters || {}} - /> -
- )} - - {sessionLoadError && ( -
-
-

Failed to Load Session

-

{sessionLoadError}

-
- -
- )} - - {/* Messages or Popular Topics */} - {messages.length > 0 || recipe ? ( - <> - {renderProgressiveMessageList(chat)} - -
- - ) : !recipe && showPopularTopics ? ( - handleSubmit(text)} /> - ) : null} - - - {chatState !== ChatState.Idle && !sessionLoadError && ( -
- 0 - ? getThinkingMessage(messages[messages.length - 1]) - : undefined - } - /> -
- )} -
- -
- setDroppedFiles([])} // Clear dropped files after processing - messages={messages} - disableAnimation={disableAnimation} - sessionCosts={sessionCosts} - setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} - recipe={recipe} - recipeAccepted={!hasNotAcceptedRecipe} - initialPrompt={initialPrompt} - toolCount={toolCount || 0} - autoSubmit={shouldAutoSubmit} - {...customChatInputProps} - /> -
- - - {recipe && ( - handleRecipeAccept(true)} - onCancel={() => handleRecipeAccept(false)} - recipeDetails={{ - title: recipe.title, - description: recipe.description, - instructions: recipe.instructions || undefined, - }} - hasSecurityWarnings={hasRecipeSecurityWarnings} - /> - )} - - {recipe?.parameters && recipe.parameters.length > 0 && !session?.user_recipe_values && ( - setView('chat')} - /> - )} - - {/*/!* Create Recipe from Session Modal *!/*/} - {/* setIsCreateRecipeModalOpen(false)}*/} - {/* sessionId={chat.sessionId}*/} - {/* onRecipeCreated={handleRecipeCreated}*/} - {/*/>*/} -
- ); -} - -export default function BaseChat(props: BaseChatProps) { - return ; -} diff --git a/ui/desktop/src/components/ChatInput.tsx b/ui/desktop/src/components/ChatInput.tsx index 16d7f9087d..0cb0ea5e06 100644 --- a/ui/desktop/src/components/ChatInput.tsx +++ b/ui/desktop/src/components/ChatInput.tsx @@ -64,10 +64,10 @@ interface ChatInputProps { handleSubmit: (e: React.FormEvent) => void; chatState: ChatState; onStop?: () => void; - commandHistory?: string[]; // Current chat's message history + commandHistory?: string[]; initialValue?: string; droppedFiles?: DroppedFile[]; - onFilesProcessed?: () => void; // Callback to clear dropped files after processing + onFilesProcessed?: () => void; setView: (view: View) => void; totalTokens?: number; accumulatedInputTokens?: number; @@ -87,7 +87,6 @@ interface ChatInputProps { recipeAccepted?: boolean; initialPrompt?: string; toolCount: number; - autoSubmit: boolean; append?: (message: Message) => void; isExtensionsLoading?: boolean; } @@ -114,7 +113,6 @@ export default function ChatInput({ recipeAccepted, initialPrompt, toolCount, - autoSubmit = false, append: _append, isExtensionsLoading = false, }: ChatInputProps) { @@ -307,7 +305,6 @@ export default function ChatInput({ const [hasUserTyped, setHasUserTyped] = useState(false); const textAreaRef = useRef(null); const timeoutRefsRef = useRef>>(new Set()); - const [didAutoSubmit, setDidAutoSubmit] = useState(false); // Use shared file drop hook for ChatInput const { @@ -939,13 +936,6 @@ export default function ChatInput({ ] ); - useEffect(() => { - if (!!autoSubmit && !didAutoSubmit) { - setDidAutoSubmit(true); - performSubmit(initialValue); - } - }, [autoSubmit, didAutoSubmit, initialValue, performSubmit]); - const handleKeyDown = (evt: React.KeyboardEvent) => { // If mention popover is open, handle arrow keys and enter if (mentionPopover.isOpen && mentionPopoverRef.current) { @@ -1504,7 +1494,7 @@ export default function ChatInput({
- {process.env.ALPHA && sessionId && ( + {sessionId && ( <>
diff --git a/ui/desktop/src/components/GooseMessage.tsx b/ui/desktop/src/components/GooseMessage.tsx index 00f6940b78..d9734c8cab 100644 --- a/ui/desktop/src/components/GooseMessage.tsx +++ b/ui/desktop/src/components/GooseMessage.tsx @@ -9,12 +9,11 @@ import { getToolRequests, getToolResponses, getToolConfirmationContent, - createToolErrorResponseMessage, + NotificationEvent, } from '../types/message'; -import { Message } from '../api'; +import { Message, confirmPermission } from '../api'; import ToolCallConfirmation from './ToolCallConfirmation'; import MessageCopyLink from './MessageCopyLink'; -import { NotificationEvent } from '../hooks/useMessageStream'; import { cn } from '../utils'; import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining'; @@ -28,7 +27,6 @@ interface GooseMessageProps { metadata?: string[]; toolCallNotifications: Map; append: (value: string) => void; - appendMessage: (message: Message) => void; isStreaming?: boolean; // Whether this message is currently being streamed } @@ -39,7 +37,6 @@ export default function GooseMessage({ messages, toolCallNotifications, append, - appendMessage, isStreaming = false, }: GooseMessageProps) { const contentRef = useRef(null); @@ -112,9 +109,25 @@ export default function GooseMessage({ if (!hasExistingResponse) { handledToolConfirmations.current.add(toolConfirmationContent.id); - appendMessage( - createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.') - ); + void (async () => { + try { + await confirmPermission({ + body: { + session_id: sessionId, + id: toolConfirmationContent.id, + action: 'deny', + }, + throwOnError: true, + }); + } catch (error) { + console.error('Failed to send tool cancellation to backend:', error); + const { toastError } = await import('../toasts'); + toastError({ + title: 'Failed to cancel tool', + msg: 'The agent may be waiting for a response. Please try restarting the session.', + }); + } + })(); } } }, [ @@ -123,7 +136,7 @@ export default function GooseMessage({ hasToolConfirmation, toolConfirmationContent, messages, - appendMessage, + sessionId, ]); return ( diff --git a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx index 01a2887518..3eee2d6a9d 100644 --- a/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx +++ b/ui/desktop/src/components/GroupedExtensionLoadingToast.tsx @@ -107,7 +107,7 @@ export function GroupedExtensionLoadingToast({ size="sm" onClick={(e) => { e.stopPropagation(); - startNewSession(ext.recoverHints, null, setView); + startNewSession(ext.recoverHints, setView); }} className="self-start" > diff --git a/ui/desktop/src/components/hub.tsx b/ui/desktop/src/components/Hub.tsx similarity index 92% rename from ui/desktop/src/components/hub.tsx rename to ui/desktop/src/components/Hub.tsx index db4266656b..de0eec3b88 100644 --- a/ui/desktop/src/components/hub.tsx +++ b/ui/desktop/src/components/Hub.tsx @@ -25,19 +25,17 @@ export default function Hub({ setView, setIsGoosehintsModalOpen, isExtensionsLoading, - resetChat, }: { setView: (view: View, viewOptions?: ViewOptions) => void; setIsGoosehintsModalOpen: (isOpen: boolean) => void; isExtensionsLoading: boolean; - resetChat: () => void; }) { const handleSubmit = async (e: React.FormEvent) => { const customEvent = e as unknown as CustomEvent; const combinedTextFromInput = customEvent.detail?.value || ''; if (combinedTextFromInput.trim()) { - await startNewSession(combinedTextFromInput, resetChat, setView); + await startNewSession(combinedTextFromInput, setView); e.preventDefault(); } }; @@ -51,10 +49,8 @@ export default function Hub({ {}} - commandHistory={[]} initialValue="" setView={setView} totalTokens={0} diff --git a/ui/desktop/src/components/Pair2.tsx b/ui/desktop/src/components/Pair.tsx similarity index 80% rename from ui/desktop/src/components/Pair2.tsx rename to ui/desktop/src/components/Pair.tsx index 6d46ce3a7d..d1cbf21d0e 100644 --- a/ui/desktop/src/components/Pair2.tsx +++ b/ui/desktop/src/components/Pair.tsx @@ -1,7 +1,12 @@ import 'react-toastify/dist/ReactToastify.css'; import { ChatType } from '../types/chat'; -import BaseChat2 from './BaseChat2'; +import BaseChat from './BaseChat'; + +export interface PairRouteState { + resumeSessionId?: string; + initialMessage?: string; +} interface PairProps { setChat: (chat: ChatType) => void; @@ -17,7 +22,7 @@ export default function Pair({ initialMessage, }: PairProps) { return ( - ; toolCallNotifications?: Map; // Make optional append?: (value: string) => void; // Make optional - appendMessage?: (message: Message) => void; // Make optional isUserMessage: (message: Message) => boolean; batchSize?: number; batchDelay?: number; @@ -46,7 +45,6 @@ export default function ProgressiveMessageList({ chat, toolCallNotifications = new Map(), append = () => {}, - appendMessage = () => {}, isUserMessage, batchSize = 20, batchDelay = 20, @@ -218,7 +216,6 @@ export default function ProgressiveMessageList({ message={message} messages={messages} append={append} - appendMessage={appendMessage} toolCallNotifications={toolCallNotifications} isStreaming={ isStreamingMessage && @@ -239,7 +236,6 @@ export default function ProgressiveMessageList({ isUserMessage, chat, append, - appendMessage, toolCallNotifications, isStreamingMessage, onMessageUpdate, diff --git a/ui/desktop/src/components/ToolCallWithResponse.tsx b/ui/desktop/src/components/ToolCallWithResponse.tsx index 8a3166e0d5..e8fa950267 100644 --- a/ui/desktop/src/components/ToolCallWithResponse.tsx +++ b/ui/desktop/src/components/ToolCallWithResponse.tsx @@ -4,10 +4,13 @@ import React, { useEffect, useRef, useState } from 'react'; import { Button } from './ui/button'; import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments'; import MarkdownContent from './MarkdownContent'; -import { ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message'; +import { + ToolRequestMessageContent, + ToolResponseMessageContent, + NotificationEvent, +} from '../types/message'; import { cn, snakeToTitleCase } from '../utils'; import { LoadingStatus } from './ui/Dot'; -import { NotificationEvent } from '../hooks/useMessageStream'; import { ChevronRight, FlaskConical } from 'lucide-react'; import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper'; import MCPUIResourceRenderer from './MCPUIResourceRenderer'; @@ -158,7 +161,8 @@ interface Progress { } const logToString = (logMessage: NotificationEvent) => { - const params = logMessage.message.params; + const message = logMessage.message as { method: string; params: unknown }; + const params = message.params as Record; // Special case for the developer system shell logs if ( @@ -174,8 +178,10 @@ const logToString = (logMessage: NotificationEvent) => { return typeof params.data === 'string' ? params.data : JSON.stringify(params.data); }; -const notificationToProgress = (notification: NotificationEvent): Progress => - notification.message.params as unknown as Progress; +const notificationToProgress = (notification: NotificationEvent): Progress => { + const message = notification.message as { method: string; params: unknown }; + return message.params as Progress; +}; // Helper function to extract extension name for tooltip const getExtensionTooltip = (toolCallName: string): string | null => { @@ -256,11 +262,17 @@ function ToolCallView({ : []; const logs = notifications - ?.filter((notification) => notification.message.method === 'notifications/message') + ?.filter((notification) => { + const message = notification.message as { method?: string }; + return message.method === 'notifications/message'; + }) .map(logToString); const progress = notifications - ?.filter((notification) => notification.message.method === 'notifications/progress') + ?.filter((notification) => { + const message = notification.message as { method?: string }; + return message.method === 'notifications/progress'; + }) .map(notificationToProgress) .reduce((map, item) => { const key = item.progressToken; diff --git a/ui/desktop/src/components/UserMessage.tsx b/ui/desktop/src/components/UserMessage.tsx index 1292d4fbeb..87613e6f4c 100644 --- a/ui/desktop/src/components/UserMessage.tsx +++ b/ui/desktop/src/components/UserMessage.tsx @@ -11,7 +11,7 @@ import { Button } from './ui/button'; interface UserMessageProps { message: Message; - onMessageUpdate?: (messageId: string, newContent: string) => void; + onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void; } export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) { @@ -85,26 +85,26 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro window.electron.logInfo(`Content changed: ${newContent}`); }, []); - // Handle save action - const handleSave = useCallback(() => { - // Exit edit mode immediately - setIsEditing(false); - - // Check if content has actually changed - if (editContent !== displayText) { - // Validate content + const handleSave = useCallback( + (editType: 'fork' | 'edit' = 'fork') => { if (editContent.trim().length === 0) { setError('Message cannot be empty'); return; } - // Update the message content through the callback + setIsEditing(false); + + if (editContent.trim() === displayText.trim()) { + return; + } + if (onMessageUpdate && message.id) { - onMessageUpdate(message.id, editContent); + onMessageUpdate(message.id, editContent, editType); setHasBeenEdited(true); } - } - }, [editContent, displayText, onMessageUpdate, message.id]); + }, + [editContent, displayText, onMessageUpdate, message.id] + ); // Handle cancel action const handleCancel = useCallback(() => { @@ -177,13 +177,31 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro {error}
)} -
- - +
+
+ Edit in Place updates this session •{' '} + Fork Session creates a new session +
+
+ + + +
) : ( diff --git a/ui/desktop/src/components/pair.tsx b/ui/desktop/src/components/pair.tsx deleted file mode 100644 index 45504c096d..0000000000 --- a/ui/desktop/src/components/pair.tsx +++ /dev/null @@ -1,182 +0,0 @@ -import { useEffect, useState } from 'react'; -import BaseChat from './BaseChat'; -import { useRecipeManager } from '../hooks/useRecipeManager'; -import { useIsMobile } from '../hooks/use-mobile'; -import { useSidebar } from './ui/sidebar'; -import { AgentState, InitializationContext } from '../hooks/useAgent'; -import 'react-toastify/dist/ReactToastify.css'; -import { cn } from '../utils'; - -import { ChatType } from '../types/chat'; -import { useSearchParams } from 'react-router-dom'; -import type { setViewType } from '../hooks/useNavigation'; - -export interface PairRouteState { - resumeSessionId?: string; - initialMessage?: string; -} - -interface PairProps { - chat: ChatType; - setChat: (chat: ChatType) => void; - setView: setViewType; - setIsGoosehintsModalOpen: (isOpen: boolean) => void; - setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void; - setAgentWaitingMessage: (msg: string | null) => void; - agentState: AgentState; - loadCurrentChat: (context: InitializationContext) => Promise; -} - -export default function Pair({ - chat, - setChat, - setView, - setIsGoosehintsModalOpen, - setFatalError, - setAgentWaitingMessage, - agentState, - loadCurrentChat, - resumeSessionId, - initialMessage, -}: PairProps & PairRouteState) { - const isMobile = useIsMobile(); - const { state: sidebarState } = useSidebar(); - const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false); - const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false); - const [messageToSubmit, setMessageToSubmit] = useState(null); - const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false); - const [loadingChat, setLoadingChat] = useState(false); - const [_searchParams, setSearchParams] = useSearchParams(); - - useEffect(() => { - const initializeFromState = async () => { - setLoadingChat(true); - try { - const chat = await loadCurrentChat({ - resumeSessionId, - setAgentWaitingMessage, - }); - setChat(chat); - setSearchParams((prev) => { - prev.set('resumeSessionId', chat.sessionId); - return prev; - }); - } catch (error) { - console.error('Agent initialization failed:', error); - - // Clear deleted session from URL and retry - if ( - error instanceof Error && - (error.message.includes('Session not found') || error.message.includes('404')) - ) { - console.log('Clearing invalid session ID from URL'); - setSearchParams((prev) => { - prev.delete('resumeSessionId'); - return prev; - }); - - try { - const chat = await loadCurrentChat({ - setAgentWaitingMessage, - }); - setChat(chat); - setSearchParams((prev) => { - prev.set('resumeSessionId', chat.sessionId); - return prev; - }); - } catch (retryError) { - handleInitializationError(retryError); - } - } else { - handleInitializationError(error); - } - } finally { - setLoadingChat(false); - } - }; - - const handleInitializationError = (error: unknown) => { - let errorMessage = 'Unknown error occurred'; - if (error) { - if (error instanceof Error) { - errorMessage = error.message; - } else if (typeof error === 'object' && error !== null) { - // Handle case where error is an object with properties - try { - errorMessage = JSON.stringify(error); - } catch { - errorMessage = Object.prototype.toString.call(error); - } - } else { - errorMessage = String(error); - } - } - setFatalError(`Agent init failure: ${errorMessage}`); - }; - - initializeFromState(); - }, [ - agentState, - setChat, - setFatalError, - setAgentWaitingMessage, - loadCurrentChat, - resumeSessionId, - setSearchParams, - ]); - - // Followed by sending the initialMessage if we have one. This will happen - // only once, unless we reset the chat in step one. - useEffect(() => { - if (agentState !== AgentState.INITIALIZED || !initialMessage || hasProcessedInitialInput) { - return; - } - - setIsTransitioningFromHub(true); - setHasProcessedInitialInput(true); - setMessageToSubmit(initialMessage); - setShouldAutoSubmit(true); - }, [agentState, initialMessage, hasProcessedInitialInput]); - - useEffect(() => { - if (agentState === AgentState.NO_PROVIDER) { - setView('welcome'); - } - }, [agentState, setView]); - - const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat, chat.recipe || null); - - const handleMessageSubmit = (message: string) => { - // Clean up any auto submit state: - setShouldAutoSubmit(false); - setIsTransitioningFromHub(false); - setMessageToSubmit(null); - console.log('Message submitted:', message); - }; - - const recipePrompt = - agentState === 'initialized' && chat.messages.length === 0 && recipeInitialPrompt; - - const initialValue = messageToSubmit || recipePrompt || undefined; - - const customChatInputProps = { - // Pass initial message from Hub or recipe prompt - initialValue, - }; - - return ( - - ); -} diff --git a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx index d063f56ca4..3b589d081e 100644 --- a/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx +++ b/ui/desktop/src/components/recipes/CreateEditRecipeModal.tsx @@ -305,7 +305,6 @@ export default function CreateEditRecipeModal({ undefined, undefined, undefined, - undefined, saved_recipe_id ); diff --git a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx index 9f36ff1d2a..57d9c50c12 100644 --- a/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx +++ b/ui/desktop/src/components/recipes/CreateRecipeFromSessionModal.tsx @@ -195,7 +195,6 @@ export default function CreateRecipeFromSessionModal({ undefined, undefined, undefined, - undefined, recipeId ); } diff --git a/ui/desktop/src/components/recipes/RecipesView.tsx b/ui/desktop/src/components/recipes/RecipesView.tsx index eea66b0b45..d8e62625bc 100644 --- a/ui/desktop/src/components/recipes/RecipesView.tsx +++ b/ui/desktop/src/components/recipes/RecipesView.tsx @@ -10,6 +10,7 @@ import { Link, Clock, Terminal, + ExternalLink, } from 'lucide-react'; import { ScrollArea } from '../ui/scroll-area'; import { Card } from '../ui/card'; @@ -92,43 +93,37 @@ export default function RecipesView() { } }; - const handleStartRecipeChat = async (recipe: Recipe, recipeId: string) => { - if (process.env.ALPHA) { - try { - const newAgent = await startAgent({ - body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, - recipe, - }, - throwOnError: true, - }); - const session = newAgent.data; - setView('pair', { - disableAnimation: true, - resumeSessionId: session.id, - }); - } catch (error) { - console.error('Failed to load recipe:', error); - setError(error instanceof Error ? error.message : 'Failed to load recipe'); - } - } else { - try { - window.electron.createChatWindow( - undefined, - undefined, - undefined, - undefined, + const handleStartRecipeChat = async (recipe: Recipe, _recipeId: string) => { + try { + const newAgent = await startAgent({ + body: { + working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, recipe, - undefined, - recipeId - ); - } catch (err) { - console.error('Failed to load recipe:', err); - setError(err instanceof Error ? err.message : 'Failed to load recipe'); - } + }, + throwOnError: true, + }); + const session = newAgent.data; + setView('pair', { + disableAnimation: true, + resumeSessionId: session.id, + }); + } catch (error) { + console.error('Failed to load recipe:', error); + setError(error instanceof Error ? error.message : 'Failed to load recipe'); } }; + const handleStartRecipeChatInNewWindow = (recipeId: string) => { + window.electron.createChatWindow( + undefined, + window.appConfig.get('GOOSE_WORKING_DIR') as string, + undefined, + undefined, + 'pair', + recipeId + ); + }; + const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => { const result = await window.electron.showMessageBox({ type: 'warning', @@ -368,6 +363,18 @@ export default function RecipesView() { > +
diff --git a/ui/desktop/src/components/sessions/SessionsView.tsx b/ui/desktop/src/components/sessions/SessionsView.tsx index dce97b2883..23d2629068 100644 --- a/ui/desktop/src/components/sessions/SessionsView.tsx +++ b/ui/desktop/src/components/sessions/SessionsView.tsx @@ -38,14 +38,10 @@ const SessionsView: React.FC = () => { const handleSelectSession = useCallback( async (sessionId: string) => { - if (process.env.ALPHA) { - setView('pair', { - disableAnimation: true, - resumeSessionId: sessionId, - }); - } else { - await loadSessionDetails(sessionId); - } + setView('pair', { + disableAnimation: true, + resumeSessionId: sessionId, + }); }, [setView] ); diff --git a/ui/desktop/src/hooks/useChatEngine.test.ts b/ui/desktop/src/hooks/useChatEngine.test.ts deleted file mode 100644 index d62befeb9d..0000000000 --- a/ui/desktop/src/hooks/useChatEngine.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * @vitest-environment jsdom - */ -import { act, renderHook } from '@testing-library/react'; -import type { Mock } from 'vitest'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useChatEngine } from './useChatEngine'; -import { getTextContent } from '../types/message'; -import { Message } from '../api'; -import { ChatType } from '../types/chat'; - -// Mock the useMessageStream hook which is a dependency of useChatEngine -vi.mock('./useMessageStream', () => ({ - useMessageStream: vi.fn(), -})); - -// Mock the sessions API which is another dependency -vi.mock('../sessions', () => ({ - fetchSessionDetails: vi.fn().mockResolvedValue({ metadata: {} }), -})); - -describe('useChatEngine', () => { - let mockUseMessageStream: Mock; - - beforeEach(async () => { - // Mock the appConfig and electron APIs on the existing window object - Object.defineProperty(window, 'appConfig', { - value: { - get: vi.fn((key: string) => { - if (key === 'GOOSE_API_HOST') return 'http://localhost'; - if (key === 'GOOSE_PORT') return '8000'; - return null; - }), - }, - writable: true, - }); - - Object.defineProperty(window, 'electron', { - value: { - logInfo: vi.fn(), - }, - writable: true, - }); - - // Dynamically import the hook so we can get a reference to the mock - const { useMessageStream } = await import('./useMessageStream'); - mockUseMessageStream = useMessageStream as Mock; - - // Reset all mocks before each test to ensure a clean state - vi.clearAllMocks(); - - // Provide a complete, default mock implementation for useMessageStream - mockUseMessageStream.mockReturnValue({ - messages: [], - append: vi.fn(), - stop: vi.fn(), - chatState: 'idle', - error: undefined, - setMessages: vi.fn(), - input: '', - setInput: vi.fn(), - handleInputChange: vi.fn(), - handleSubmit: vi.fn(), - updateMessageStreamBody: vi.fn(), - notifications: [], - sessionMetadata: undefined, - setError: vi.fn(), - }); - }); - - describe('onMessageUpdate', () => { - it('should truncate history and append the updated message when a message is edited', () => { - // --- 1. ARRANGE --- - const metadata = { - agentVisible: true, - userVisible: true, - }; - - const initialMessages: Message[] = [ - { - id: '1', - role: 'user', - content: [{ type: 'text', text: 'First message' }], - created: 0, - metadata, - }, - { - id: '2', - role: 'assistant', - content: [{ type: 'text', text: 'First response' }], - created: 1, - metadata, - }, - { - id: '3', - role: 'user', - content: [{ type: 'text', text: 'Message to be edited' }], - created: 2, - metadata, - }, - { - id: '4', - role: 'assistant', - content: [{ type: 'text', text: 'Response to be deleted' }], - created: 3, - metadata, - }, - ]; - - const mockSetMessages = vi.fn(); - const mockAppend = vi.fn(); - - // Configure the mock to return specific values for this test case - mockUseMessageStream.mockReturnValue({ - messages: initialMessages, - append: mockAppend, - setMessages: mockSetMessages, - notifications: [], - stop: vi.fn(), - chatState: 'idle', - error: undefined, - input: '', - setInput: vi.fn(), - handleInputChange: vi.fn(), - handleSubmit: vi.fn(), - updateMessageStreamBody: vi.fn(), - sessionMetadata: undefined, - setError: vi.fn(), - }); - - const mockChat: ChatType = { - sessionId: 'test-chat', - messages: initialMessages, - name: 'Test Chat', - messageHistoryIndex: 0, - }; - - // Render the hook with our test setup - const { result } = renderHook(() => - useChatEngine({ - chat: mockChat, - setChat: vi.fn(), - }) - ); - - const messageIdToUpdate = '3'; - const newContent = 'This is the edited message.'; - - // --- 2. ACT --- - // Call the function we want to test - act(() => { - result.current.onMessageUpdate(messageIdToUpdate, newContent); - }); - - // --- 3. ASSERT --- - // Verify that setMessages was called with the correctly truncated history - const expectedTruncatedHistory = initialMessages.slice(0, 2); - expect(mockSetMessages).toHaveBeenCalledWith(expectedTruncatedHistory); - - // Verify that append was called with the new message - expect(mockAppend).toHaveBeenCalledTimes(1); - const appendedMessage = mockAppend.mock.calls[0][0]; - expect(getTextContent(appendedMessage)).toBe(newContent); - expect(appendedMessage.role).toBe('user'); - }); - }); -}); diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts deleted file mode 100644 index 19a333aefd..0000000000 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ /dev/null @@ -1,473 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { getApiUrl } from '../config'; -import { useMessageStream } from './useMessageStream'; -import { LocalMessageStorage } from '../utils/localMessageStorage'; -import { createUserMessage, getTextContent, ToolResponseMessageContent } from '../types/message'; -import { getSession, Message } from '../api'; -import { ChatType } from '../types/chat'; -import { ChatState } from '../types/chatState'; - -// Helper function to determine if a message is a user message -const isUserMessage = (message: Message): boolean => { - if (message.role === 'assistant') { - return false; - } - return !message.content.every((c) => c.type === 'toolConfirmationRequest'); -}; - -interface UseChatEngineProps { - chat: ChatType; - setChat: (chat: ChatType) => void; - onMessageStreamFinish?: () => void; - onMessageSent?: () => void; // Add callback for when message is sent -} - -export const useChatEngine = ({ - chat, - setChat, - onMessageStreamFinish, - onMessageSent, -}: UseChatEngineProps) => { - const [lastInteractionTime, setLastInteractionTime] = useState(Date.now()); - const [sessionTokenCount, setSessionTokenCount] = useState(0); - const [sessionInputTokens, setSessionInputTokens] = useState(0); - const [sessionOutputTokens, setSessionOutputTokens] = useState(0); - const [localInputTokens, setLocalInputTokens] = useState(0); - const [localOutputTokens, setLocalOutputTokens] = useState(0); - const [powerSaveTimeoutId, setPowerSaveTimeoutId] = useState(null); - - // Track pending edited message - const [pendingEdit, setPendingEdit] = useState<{ id: string; content: string } | null>(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 stopPowerSaveBlocker = useCallback(() => { - try { - window.electron.stopPowerSaveBlocker(); - } catch (error) { - console.error('Failed to stop power save blocker:', error); - } - - // Clear timeout if it exists - if (powerSaveTimeoutId) { - window.clearTimeout(powerSaveTimeoutId); - setPowerSaveTimeoutId(null); - } - }, [powerSaveTimeoutId]); - - const { - messages, - append: originalAppend, - stop, - chatState, - error, - setMessages, - input: _input, - setInput: _setInput, - handleInputChange: _handleInputChange, - updateMessageStreamBody, - notifications, - session, - setError, - tokenState, - } = useMessageStream({ - api: getApiUrl('/reply'), - id: chat.sessionId, - initialMessages: chat.messages, - body: { - session_id: chat.sessionId, - session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'), - ...(chat.recipe?.title - ? { - recipe_name: chat.recipe.title, - recipe_version: chat.recipe?.version ?? 'unknown', - } - : {}), - }, - onFinish: async (_message, _reason) => { - stopPowerSaveBlocker(); - - const timeSinceLastInteraction = Date.now() - lastInteractionTime; - window.electron.logInfo('last interaction:' + lastInteractionTime); - if (timeSinceLastInteraction > 60000) { - // 60000ms = 1 minute - window.electron.showNotification({ - title: 'Goose finished the task.', - body: 'Click here to expand.', - }); - } - - // Always emit refresh event when message stream finishes for new sessions - // Check if this is a new session by looking at the current session ID format - const isNewSession = chat.sessionId && chat.sessionId.match(/^\d{8}_\d{6}$/); - if (isNewSession) { - console.log( - 'ChatEngine: Message stream finished for new session, emitting message-stream-finished event' - ); - // Emit event to trigger session refresh - window.dispatchEvent(new CustomEvent('message-stream-finished')); - } - - onMessageStreamFinish?.(); - }, - onError: (error) => { - stopPowerSaveBlocker(); - - console.log( - 'CHAT ENGINE RECEIVED ERROR FROM MESSAGE STREAM:', - JSON.stringify( - { - errorMessage: error.message, - errorName: error.name, - isTokenLimitError: (error as Error & { isTokenLimitError?: boolean }).isTokenLimitError, - errorStack: error.stack, - timestamp: new Date().toISOString(), - sessionId: chat.sessionId, - }, - null, - 2 - ) - ); - }, - }); - - // Wrap append to store messages in global history - const append = useCallback( - (messageOrString: Message | string) => { - const message = - typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString; - storeMessageInHistory(message); - - // If this is the first message in a new session, trigger a refresh immediately - // Only trigger if we're starting a completely new session (no existing messages) - if (messages.length === 0 && chat.messages.length === 0) { - // Emit event to indicate a new session is being created - window.dispatchEvent(new CustomEvent('session-created')); - } - - return originalAppend(message); - }, - [originalAppend, storeMessageInHistory, messages.length, chat.messages.length] - ); - - // Simple token estimation function (roughly 4 characters per token) - const estimateTokens = (text: string): number => { - return Math.ceil(text.length / 4); - }; - - // Calculate token counts from messages - useEffect(() => { - let inputTokens = 0; - let outputTokens = 0; - - messages.forEach((message) => { - const textContent = getTextContent(message); - if (textContent) { - const tokens = estimateTokens(textContent); - if (message.role === 'user') { - inputTokens += tokens; - } else if (message.role === 'assistant') { - outputTokens += tokens; - } - } - }); - - setLocalInputTokens(inputTokens); - setLocalOutputTokens(outputTokens); - }, [messages]); - - // Update chat messages when they change - useEffect(() => { - // @ts-expect-error - TypeScript being overly strict about the return type - setChat((prevChat: ChatType) => ({ ...prevChat, messages })); - }, [messages, setChat]); - - useEffect(() => { - const fetchSessionTokens = async () => { - try { - const response = await getSession({ - path: { session_id: chat.sessionId }, - throwOnError: true, - }); - const sessionDetails = response.data; - setSessionTokenCount(sessionDetails.total_tokens || 0); - setSessionInputTokens(sessionDetails.accumulated_input_tokens || 0); - setSessionOutputTokens(sessionDetails.accumulated_output_tokens || 0); - } catch (err) { - console.error('Error fetching session token count:', err); - } - }; - // Only fetch session tokens when chat state is idle to avoid resetting during streaming - if (chat.sessionId && chatState === ChatState.Idle) { - fetchSessionTokens(); - } - }, [chat.sessionId, messages, chatState]); - - // Update token counts when session changes from the message stream - useEffect(() => { - if (session) { - setSessionTokenCount(session.total_tokens || 0); - setSessionInputTokens(session.accumulated_input_tokens || 0); - setSessionOutputTokens(session.accumulated_output_tokens || 0); - } - }, [session]); - - useEffect(() => { - return () => { - if (powerSaveTimeoutId) { - window.clearTimeout(powerSaveTimeoutId); - } - try { - window.electron.stopPowerSaveBlocker(); - } catch (error) { - console.error('Failed to stop power save blocker during cleanup:', error); - } - }; - }, [powerSaveTimeoutId]); - - // Handle submit - const handleSubmit = useCallback( - (combinedTextFromInput: string, onSummaryReset?: () => void) => { - if (combinedTextFromInput.trim()) { - try { - window.electron.startPowerSaveBlocker(); - } catch (error) { - console.error('Failed to start power save blocker:', error); - } - - setLastInteractionTime(Date.now()); - - // Set a timeout to automatically stop the power save blocker after 15 minutes - const timeoutId = window.setTimeout( - () => { - console.warn('Power save blocker timeout - stopping automatically after 15 minutes'); - stopPowerSaveBlocker(); - }, - 15 * 60 * 1000 - ); - - setPowerSaveTimeoutId(timeoutId); - - const userMessage = createUserMessage(combinedTextFromInput.trim()); - - if (onSummaryReset) { - onSummaryReset(); - window.setTimeout(() => { - append(userMessage); - onMessageSent?.(); - }, 150); - } else { - append(userMessage); - onMessageSent?.(); - } - } else { - // If nothing was actually submitted (e.g. empty input and no images pasted) - stopPowerSaveBlocker(); - } - }, - [append, onMessageSent, stopPowerSaveBlocker] - ); - - // Handle stopping the message stream - const onStopGoose = useCallback(() => { - stop(); - setLastInteractionTime(Date.now()); - stopPowerSaveBlocker(); - - // Handle stopping the message stream - const lastMessage = messages[messages.length - 1]; - - // Check if there are any messages before proceeding - if (!lastMessage) { - return; - } - - // check if the last user message has any tool response(s) - const isToolResponse = lastMessage.content.some( - (content): content is ToolResponseMessageContent => content.type == 'toolResponse' - ); - - // isUserMessage also checks if the message is a toolConfirmationRequest - // check if the last message is a real user's message - if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) { - const textValue = getTextContent(lastMessage); - _setInput(textValue); - - // Also add to local storage history as a backup so cmd+up can retrieve it - if (textValue.trim()) { - LocalMessageStorage.addMessage(textValue.trim()); - } - - // Remove the last user message if it's the most recent one - if (messages.length > 1) { - setMessages(messages.slice(0, -1)); - } else { - setMessages([]); - } - } else if (!isUserMessage(lastMessage)) { - const toolRequests: [string, Record][] = lastMessage.content - .filter( - (content) => content.type === 'toolRequest' || content.type === 'toolConfirmationRequest' - ) - .map((content) => { - if (content.type === 'toolRequest') { - return [content.id, content.toolCall]; - } else { - const toolCall = { - status: 'success', - value: { - name: content.toolName, - arguments: content.arguments, - }, - }; - return [content.id, toolCall]; - } - }); - - if (toolRequests.length !== 0) { - // This means we were interrupted during a tool request - // Create tool responses for all interrupted tool requests - - let responseMessage: Message = { - role: 'user', - created: Date.now(), - content: [], - metadata: { userVisible: true, agentVisible: true }, - }; - - const notification = 'Interrupted by the user to make a correction'; - - // generate a response saying it was interrupted for each tool request - for (const [reqId, _] of toolRequests) { - const toolResponse: ToolResponseMessageContent = { - type: 'toolResponse', - id: reqId, - toolResult: { - status: 'error', - error: notification, - }, - }; - - responseMessage.content.push(toolResponse); - } - // Use an immutable update to add the response message to the messages array - setMessages([...messages, responseMessage]); - } - } - }, [stop, messages, _setInput, setMessages, stopPowerSaveBlocker]); - - // Since server now handles all filtering, we just use messages directly - const filteredMessages = useMemo(() => { - return messages; - }, [messages]); - - // Generate command history from messages - const commandHistory = useMemo(() => { - return filteredMessages - .reduce((history, message) => { - if (isUserMessage(message)) { - const text = getTextContent(message).trim(); - if (text) { - history.push(text); - } - } - return history; - }, []) - .reverse(); - }, [filteredMessages]); - - // Process tool call notifications - const toolCallNotifications = useMemo(() => { - return notifications.reduce((map, item) => { - const key = item.request_id; - if (!map.has(key)) { - map.set(key, []); - } - map.get(key).push(item); - return map; - }, new Map()); - }, [notifications]); - - // Handle message updates from the UI - const onMessageUpdate = useCallback( - (messageId: string, newContent: string) => { - const messageIndex = messages.findIndex((msg) => msg.id === messageId); - - if (messageIndex !== -1) { - // Truncate the history to the point *before* the edited message. - const history = messages.slice(0, messageIndex); - - // Set the truncated history. - setMessages(history); - - // Instead of setTimeout, set pendingEdit which will be handled in useEffect - setPendingEdit({ id: messageId, content: newContent }); - } - }, - [messages, setMessages, setPendingEdit] - ); - - // Listen for pending edit and append message after messages updated - useEffect(() => { - if (pendingEdit) { - const updatedMessage = createUserMessage(pendingEdit.content); - append(updatedMessage); - setPendingEdit(null); // Reset after processing - } - }, [pendingEdit, append]); - - return { - // Core message data - messages, - filteredMessages, - - // Message stream controls - append, - stop, - chatState, - error, - setMessages, - - // Input controls - input: _input, - setInput: _setInput, - handleInputChange: _handleInputChange, - - // Event handlers - handleSubmit, - onStopGoose, - - // Token and session data - sessionTokenCount, - sessionInputTokens, - sessionOutputTokens, - localInputTokens, - localOutputTokens, - tokenState, - - // UI helpers - commandHistory, - toolCallNotifications, - - // Stream utilities - updateMessageStreamBody, - sessionMetadata: session, - - // Utilities - isUserMessage, - - // Error management - clearError: () => setError(undefined), - - // New functions for message editing - onMessageUpdate, - }; -}; diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index e084b04d6e..a8614f2c19 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChatState } from '../types/chatState'; import { @@ -12,45 +12,20 @@ import { updateSessionUserRecipeValues, } from '../api'; -import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message'; +import { + createUserMessage, + getCompactingMessage, + getThinkingMessage, + NotificationEvent, +} from '../types/message'; +import { errorMessage } from '../utils/conversionUtils'; const resultsCache = new Map(); -// Debug logging - set to false in production -const DEBUG_CHAT_STREAM = true; - -const log = { - session: (action: string, sessionId: string, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:session] ${action}`, { - sessionId: sessionId.slice(0, 8), - ...details, - }); - }, - messages: (action: string, count: number, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:messages] ${action}`, { - count, - ...details, - }); - }, - stream: (action: string, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:stream] ${action}`, details); - }, - state: (newState: ChatState, details?: Record) => { - if (!DEBUG_CHAT_STREAM) return; - console.log(`[useChatStream:state] → ${newState}`, details); - }, - error: (context: string, error: unknown) => { - console.error(`[useChatStream:error] ${context}`, error); - }, -}; - interface UseChatStreamProps { sessionId: string; onStreamFinish: () => void; - initialMessage?: string; + onSessionLoaded?: () => void; } interface UseChatStreamReturn { @@ -62,6 +37,12 @@ interface UseChatStreamReturn { stopStreaming: () => void; sessionLoadError?: string; tokenState: TokenState; + notifications: Map; + onMessageUpdate: ( + messageId: string, + newContent: string, + editType?: 'fork' | 'edit' + ) => Promise; } function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] { @@ -92,78 +73,67 @@ async function streamFromResponse( updateMessages: (messages: Message[]) => void, updateTokenState: (tokenState: TokenState) => void, updateChatState: (state: ChatState) => void, + updateNotifications: (notification: NotificationEvent) => void, onFinish: (error?: string) => void ): Promise { - let messageEventCount = 0; let currentMessages = initialMessages; try { - log.stream('reading-events'); - for await (const event of stream) { switch (event.type) { case 'Message': { - messageEventCount++; const msg = event.message; currentMessages = pushMessage(currentMessages, msg); - if (getCompactingMessage(msg)) { - log.state(ChatState.Compacting, { reason: 'compacting notification' }); + const hasToolConfirmation = msg.content.some( + (content) => content.type === 'toolConfirmationRequest' + ); + + if (hasToolConfirmation) { + updateChatState(ChatState.WaitingForUserInput); + } else if (getCompactingMessage(msg)) { updateChatState(ChatState.Compacting); } else if (getThinkingMessage(msg)) { - log.state(ChatState.Thinking, { reason: 'thinking notification' }); updateChatState(ChatState.Thinking); - } - - if (messageEventCount % 10 === 0) { - log.stream('message-chunk', { - eventCount: messageEventCount, - messageCount: currentMessages.length, - }); + } else { + updateChatState(ChatState.Streaming); } updateTokenState(event.token_state); - updateMessages(currentMessages); break; } case 'Error': { - log.error('stream event error', event.error); onFinish('Stream error: ' + event.error); return; } case 'Finish': { - log.stream('finish-event', { reason: event.reason }); onFinish(); return; } case 'ModelChange': { - log.stream('model-change', { - model: event.model, - mode: event.mode, - }); break; } case 'UpdateConversation': { - log.messages('conversation-update', event.conversation.length); // WARNING: Since Message handler uses this local variable, we need to update it here to avoid the client clobbering it. // Longterm fix is to only send the agent the new messages, not the entire conversation. currentMessages = event.conversation; updateMessages(event.conversation); break; } - case 'Notification': + case 'Notification': { + updateNotifications(event as NotificationEvent); + break; + } case 'Ping': break; } } - log.stream('events-complete', { messageEvents: messageEventCount }); onFinish(); } catch (error) { if (error instanceof Error && error.name !== 'AbortError') { - log.error('stream read error', error); - onFinish('Stream error: ' + error); + onFinish('Stream error: ' + errorMessage(error)); } } } @@ -171,7 +141,7 @@ async function streamFromResponse( export function useChatStream({ sessionId, onStreamFinish, - initialMessage, + onSessionLoaded, }: UseChatStreamProps): UseChatStreamReturn { const [messages, setMessages] = useState([]); const messagesRef = useRef([]); @@ -186,6 +156,7 @@ export function useChatStream({ accumulatedOutputTokens: 0, accumulatedTotalTokens: 0, }); + const [notifications, setNotifications] = useState([]); const abortControllerRef = useRef(null); useEffect(() => { @@ -194,45 +165,55 @@ export function useChatStream({ } }, [sessionId, session, messages]); - const renderCountRef = useRef(0); - renderCountRef.current += 1; - console.log(`useChatStream render #${renderCountRef.current}, ${session?.id}`); - - const setMessagesAndLog = useCallback((newMessages: Message[], logContext: string) => { - log.messages(logContext, newMessages.length, { - lastMessageRole: newMessages[newMessages.length - 1]?.role, - lastMessageId: newMessages[newMessages.length - 1]?.id?.slice(0, 8), - }); + const updateMessages = useCallback((newMessages: Message[]) => { setMessages(newMessages); messagesRef.current = newMessages; }, []); + const updateNotifications = useCallback((notification: NotificationEvent) => { + setNotifications((prev) => [...prev, notification]); + }, []); + const onFinish = useCallback( - (error?: string): void => { + async (error?: string): Promise => { if (error) { setSessionLoadError(error); } + + const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/); + if (isNewSession) { + console.log( + 'useChatStream: Message stream finished for new session, emitting message-stream-finished event' + ); + window.dispatchEvent(new CustomEvent('message-stream-finished')); + } + setChatState(ChatState.Idle); onStreamFinish(); }, - [onStreamFinish] + [onStreamFinish, sessionId] ); // Load session on mount or sessionId change useEffect(() => { if (!sessionId) return; + const cached = resultsCache.get(sessionId); + if (cached) { + setSession(cached.session); + updateMessages(cached.messages); + setChatState(ChatState.Idle); + return; + } + // Reset state when sessionId changes - log.session('loading', sessionId); - setMessagesAndLog([], 'session-reset'); + updateMessages([]); setSession(undefined); setSessionLoadError(undefined); setChatState(ChatState.LoadingConversation); let cancelled = false; - log.state(ChatState.LoadingConversation, { reason: 'session load start' }); - (async () => { try { const response = await resumeAgent({ @@ -242,26 +223,20 @@ export function useChatStream({ }, throwOnError: true, }); - if (cancelled) return; + + if (cancelled) { + return; + } const session = response.data; - log.session('loaded', sessionId, { - messageCount: session?.conversation?.length || 0, - name: session?.name, - }); - setSession(session); - setMessagesAndLog(session?.conversation || [], 'load-session'); - - log.state(ChatState.Idle, { reason: 'session load complete' }); + updateMessages(session?.conversation || []); setChatState(ChatState.Idle); + onSessionLoaded?.(); } catch (error) { if (cancelled) return; - log.error('session load failed', error); - setSessionLoadError(error instanceof Error ? error.message : String(error)); - - log.state(ChatState.Idle, { reason: 'session load error' }); + setSessionLoadError(errorMessage(error)); setChatState(ChatState.Idle); } })(); @@ -269,25 +244,43 @@ export function useChatStream({ return () => { cancelled = true; }; - }, [sessionId, setMessagesAndLog]); + }, [sessionId, updateMessages, onSessionLoaded]); const handleSubmit = useCallback( async (userMessage: string) => { - log.messages('user-submit', messagesRef.current.length + 1, { - userMessageLength: userMessage.length, - }); + // Guard: Don't submit if session hasn't been loaded yet + if (!session || chatState === ChatState.LoadingConversation) { + return; + } - const currentMessages = [...messagesRef.current, createUserMessage(userMessage)]; - setMessagesAndLog(currentMessages, 'user-entered'); + const hasExistingMessages = messagesRef.current.length > 0; + const hasNewMessage = userMessage.trim().length > 0; + + // Don't submit if there's no message and no conversation to continue + if (!hasNewMessage && !hasExistingMessages) { + return; + } + + // Emit session-created event for first message in a new session + if (!hasExistingMessages && hasNewMessage) { + window.dispatchEvent(new CustomEvent('session-created')); + } + + // Build message list: add new message if provided, otherwise continue with existing + const currentMessages = hasNewMessage + ? [...messagesRef.current, createUserMessage(userMessage)] + : [...messagesRef.current]; + + // Update UI with new message before streaming + if (hasNewMessage) { + updateMessages(currentMessages); + } - log.state(ChatState.Streaming, { reason: 'user submit' }); setChatState(ChatState.Streaming); - + setNotifications([]); abortControllerRef.current = new AbortController(); try { - log.stream('request-start', { sessionId: sessionId.slice(0, 8) }); - const { stream } = await reply({ body: { session_id: sessionId, @@ -297,30 +290,26 @@ export function useChatStream({ signal: abortControllerRef.current.signal, }); - log.stream('stream-started'); - await streamFromResponse( stream, currentMessages, - (messages: Message[]) => setMessagesAndLog(messages, 'streaming'), + updateMessages, setTokenState, setChatState, + updateNotifications, onFinish ); - - log.stream('stream-complete'); } catch (error) { // AbortError is expected when user stops streaming if (error instanceof Error && error.name === 'AbortError') { - log.stream('stream-aborted'); + // Silently handle abort } else { // Unexpected error during fetch setup (streamFromResponse handles its own errors) - log.error('submit failed', error); - onFinish('Submit error: ' + (error instanceof Error ? error.message : String(error))); + onFinish('Submit error: ' + errorMessage(error)); } } }, - [sessionId, setMessagesAndLog, onFinish] + [sessionId, session, chatState, updateMessages, updateNotifications, onFinish] ); const setRecipeUserParams = useCallback( @@ -361,25 +350,86 @@ export function useChatStream({ } }, [session]); - useEffect(() => { - if (initialMessage && session && messages.length === 0 && chatState === ChatState.Idle) { - log.messages('auto-submit-initial', 0, { initialMessage: initialMessage.slice(0, 50) }); - handleSubmit(initialMessage); - } - }, [initialMessage, session, messages.length, chatState, handleSubmit]); - const stopStreaming = useCallback(() => { - log.stream('stop-requested'); abortControllerRef.current?.abort(); - log.state(ChatState.Idle, { reason: 'user stopped streaming' }); setChatState(ChatState.Idle); }, []); + const onMessageUpdate = useCallback( + async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => { + try { + const { editMessage } = await import('../api'); + const message = messagesRef.current.find((m) => m.id === messageId); + + if (!message) { + throw new Error(`Message with id ${messageId} not found in current messages`); + } + + const response = await editMessage({ + path: { + session_id: sessionId, + }, + body: { + timestamp: message.created, + editType, + }, + throwOnError: true, + }); + + const targetSessionId = response.data?.sessionId; + if (!targetSessionId) { + throw new Error('No session ID returned from edit_message'); + } + + if (editType === 'fork') { + const event = new CustomEvent('session-forked', { + detail: { + newSessionId: targetSessionId, + shouldStartAgent: true, + editedMessage: newContent, + }, + }); + window.dispatchEvent(event); + window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`); + } else { + const { getSession } = await import('../api'); + const sessionResponse = await getSession({ + path: { session_id: targetSessionId }, + throwOnError: true, + }); + + if (sessionResponse.data?.conversation) { + updateMessages(sessionResponse.data.conversation); + } + await handleSubmit(newContent); + } + } catch (error) { + const errorMsg = errorMessage(error); + console.error('Failed to edit message:', error); + const { toastError } = await import('../toasts'); + toastError({ + title: 'Failed to edit message', + msg: errorMsg, + }); + } + }, + [sessionId, handleSubmit, updateMessages] + ); + const cached = resultsCache.get(sessionId); const maybe_cached_messages = session ? messages : cached?.messages || []; const maybe_cached_session = session ?? cached?.session; - console.log('>> returning', sessionId, Date.now(), maybe_cached_messages, chatState); + const notificationsMap = useMemo(() => { + return notifications.reduce((map, notification) => { + const key = notification.request_id; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(notification); + return map; + }, new Map()); + }, [notifications]); return { sessionLoadError, @@ -390,5 +440,7 @@ export function useChatStream({ stopStreaming, setRecipeUserParams, tokenState, + notifications: notificationsMap, + onMessageUpdate, }; } diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts deleted file mode 100644 index 4e36c7d9ec..0000000000 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'; -import useSWR from 'swr'; -import { - createUserMessage, - getThinkingMessage, - getCompactingMessage, - hasCompletedToolCalls, -} from '../types/message'; -import { Conversation, Message, Role, TokenState } from '../api'; - -import { getSession, Session } from '../api'; -import { ChatState } from '../types/chatState'; - -let messageIdCounter = 0; - -function generateMessageId(): string { - return `msg-${Date.now()}-${++messageIdCounter}`; -} - -// Ensure TextDecoder is available in the global scope -const TextDecoder = globalThis.TextDecoder; - -type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; - -export interface NotificationEvent { - type: 'Notification'; - request_id: string; - message: { - method: string; - params: { - [key: string]: JsonValue; - }; - }; -} - -// Event types for SSE stream -type MessageEvent = - | { type: 'Message'; message: Message; token_state: TokenState } - | { type: 'Error'; error: string } - | { type: 'Finish'; reason: string; token_state: TokenState } - | { type: 'ModelChange'; model: string; mode: string } - | { type: 'UpdateConversation'; conversation: Conversation } - | NotificationEvent; - -export interface UseMessageStreamOptions { - /** - * The API endpoint that accepts a `{ messages: Message[] }` object and returns - * a stream of messages. Defaults to `/api/chat/reply`. - */ - api?: string; - - /** - * A unique identifier for the chat. If not provided, a random one will be - * generated. When provided, the hook with the same `id` will - * have shared states across components. - */ - id?: string; - - /** - * Initial messages of the chat. Useful to load an existing chat history. - */ - initialMessages?: Message[]; - - /** - * Initial input of the chat. - */ - initialInput?: string; - - /** - * Callback function to be called when a tool call is received. - * You can optionally return a result for the tool call. - */ - _onToolCall?: (toolCall: Record) => void | Promise | unknown; - - /** - * Callback function to be called when the API response is received. - */ - onResponse?: (response: Response) => void | Promise; - - /** - * Callback function to be called when the assistant message is finished streaming. - */ - onFinish?: (message: Message, reason: string) => void; - - /** - * Callback function to be called when an error is encountered. - */ - onError?: (error: Error) => void; - - /** - * HTTP headers to be sent with the API request. - */ - headers?: Record | HeadersInit; - - /** - * Extra body object to be sent with the API request. - */ - body?: object; - - /** - * Maximum number of sequential LLM calls (steps), e.g. when you use tool calls. - * Default is 1. - */ - maxSteps?: number; -} - -export interface UseMessageStreamHelpers { - /** Current messages in the chat */ - messages: Message[]; - - /** The error object of the API request */ - error: undefined | Error; - - /** - * Append a user message to the chat list. This triggers the API call to fetch - * the assistant's response. - */ - append: (message: Message | string) => Promise; - - /** - * Reload the last AI chat response for the given chat history. - */ - reload: () => Promise; - - /** - * Abort the current request immediately. - */ - stop: () => void; - - /** - * Update the `messages` state locally. - */ - setMessages: (messages: Message[] | ((messages: Message[]) => Message[])) => void; - - /** The current value of the input */ - input: string; - - /** setState-powered method to update the input value */ - setInput: React.Dispatch>; - - /** An input/textarea-ready onChange handler to control the value of the input */ - handleInputChange: ( - e: React.ChangeEvent | React.ChangeEvent - ) => void; - - /** Form submission handler to automatically reset input and append a user message */ - handleSubmit: (event?: { preventDefault?: () => void }) => void; - - /** Current chat state (idle, thinking, streaming, waiting for user input) */ - chatState: ChatState; - - /** Add a tool result to a tool call */ - addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void; - - /** Modify body (session id and/or work dir mid-stream) **/ - updateMessageStreamBody?: (newBody: object) => void; - - notifications: NotificationEvent[]; - - /** Current model info from the backend */ - currentModelInfo: { model: string; mode: string } | null; - - /** Session including token counts */ - session: Session | null; - - /** Clear error state */ - setError: (error: Error | undefined) => void; - - /** Real-time token state from server */ - tokenState: TokenState; -} - -/** - * Hook for streaming messages directly from the server using the native Goose message format - */ -export function useMessageStream({ - api = '/api/chat/reply', - id, - initialMessages = [], - initialInput = '', - onResponse, - onFinish, - onError, - headers, - body, - maxSteps = 1, -}: UseMessageStreamOptions = {}): UseMessageStreamHelpers { - // Generate a unique id for the chat if not provided - const hookId = useId(); - const idKey = id ?? hookId; - const chatKey = typeof api === 'string' ? [api, idKey] : idKey; - - // Store the chat state in SWR, using the chatId as the key to share states - const { data: messages, mutate } = useSWR([chatKey, 'messages'], null, { - fallbackData: initialMessages, - }); - - const [notifications, setNotifications] = useState([]); - const [currentModelInfo, setCurrentModelInfo] = useState<{ model: string; mode: string } | null>( - null - ); - const [session, setSession] = useState(null); - const [tokenState, setTokenState] = useState({ - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - accumulatedInputTokens: 0, - accumulatedOutputTokens: 0, - accumulatedTotalTokens: 0, - }); - - // expose a way to update the body so we can update the session id when CLE occurs - const updateMessageStreamBody = useCallback((newBody: object) => { - extraMetadataRef.current.body = { - ...extraMetadataRef.current.body, - ...newBody, - }; - }, []); - - // Keep the latest messages in a ref - const messagesRef = useRef(messages || []); - useEffect(() => { - messagesRef.current = messages || []; - }, [messages]); - - // Track chat state (idle, thinking, streaming, waiting for user input) - const { data: chatState = ChatState.Idle, mutate: mutateChatState } = useSWR( - [chatKey, 'chatState'], - null - ); - - const { data: error = undefined, mutate: setError } = useSWR( - [chatKey, 'error'], - null - ); - - // Abort controller to cancel the current API call - const abortControllerRef = useRef(null); - - // Extra metadata for requests - const extraMetadataRef = useRef({ - headers, - body, - }); - - useEffect(() => { - extraMetadataRef.current = { - headers, - body, - }; - }, [headers, body]); - - // TODO: not this? - const [, forceUpdate] = useReducer((x) => x + 1, 0); - - // Process the SSE stream from the server - const processMessageStream = useCallback( - async (response: Response, currentMessages: Message[]) => { - if (!response.body) { - throw new Error('Response body is empty'); - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - let buffer = ''; - - try { - let running = true; - while (running) { - const { done, value } = await reader.read(); - if (done) { - running = false; - break; - } - - // Decode the chunk and add it to our buffer - buffer += decoder.decode(value, { stream: true }); - - // Process complete SSE events - const events = buffer.split('\n\n'); - buffer = events.pop() || ''; // Keep the last incomplete event in the buffer - - for (const event of events) { - if (event.startsWith('data: ')) { - try { - const data = event.slice(6); // Remove 'data: ' prefix - const parsedEvent = JSON.parse(data) as MessageEvent; - - switch (parsedEvent.type) { - case 'Message': { - // Transition from waiting to streaming on first message - mutateChatState(ChatState.Streaming); - - setTokenState(parsedEvent.token_state); - - // Create a new message object with the properties preserved or defaulted - const newMessage: Message = { - ...parsedEvent.message, - id: parsedEvent.message.id || undefined, - role: parsedEvent.message.role as Role, - created: parsedEvent.message.created || Date.now(), - content: parsedEvent.message.content || [], - }; - - // Update messages with the new message - if ( - newMessage.id && - currentMessages.length > 0 && - currentMessages[currentMessages.length - 1].id === newMessage.id - ) { - // If the last message has the same ID, update it instead of adding a new one - const lastMessage = currentMessages[currentMessages.length - 1]; - lastMessage.content = [...lastMessage.content, ...newMessage.content]; - forceUpdate(); - } else { - currentMessages = [...currentMessages, newMessage]; - } - - // Check if this message contains tool confirmation requests - const hasToolConfirmation = newMessage.content.some( - (content) => content.type === 'toolConfirmationRequest' - ); - - if (hasToolConfirmation) { - mutateChatState(ChatState.WaitingForUserInput); - } - - if (getCompactingMessage(newMessage)) { - mutateChatState(ChatState.Compacting); - } else if (getThinkingMessage(newMessage)) { - mutateChatState(ChatState.Thinking); - } - - mutate(currentMessages, false); - break; - } - - case 'Notification': { - const newNotification = { - ...parsedEvent, - }; - setNotifications((prev) => [...prev, newNotification]); - break; - } - - case 'ModelChange': { - // Update the current model in the frontend - const modelInfo = { - model: parsedEvent.model, - mode: parsedEvent.mode, - }; - setCurrentModelInfo(modelInfo); - break; - } - - case 'UpdateConversation': { - // WARNING: Since Message handler uses this local variable, we need to update it here to avoid the client clobbering it. - // Longterm fix is to only send the agent the new messages, not the entire conversation. - currentMessages = parsedEvent.conversation; - setMessages(parsedEvent.conversation); - break; - } - - case 'Error': { - // Always throw the error so it gets caught and sets the error state - // This ensures the retry UI appears for ALL errors - throw new Error(parsedEvent.error); - } - - case 'Finish': { - setTokenState(parsedEvent.token_state); - - if (onFinish && currentMessages.length > 0) { - const lastMessage = currentMessages[currentMessages.length - 1]; - onFinish(lastMessage, parsedEvent.reason); - } - - const sessionId = (extraMetadataRef.current.body as Record) - ?.session_id as string; - if (sessionId) { - const sessionResponse = await getSession({ - path: { session_id: sessionId }, - throwOnError: true, - }); - - if (sessionResponse.data) { - setSession(sessionResponse.data); - } - } - break; - } - } - } catch (e) { - console.error('Error parsing SSE event:', e); - if (onError && e instanceof Error) { - onError(e); - } - // Don't re-throw here, let the error be handled by the outer catch - // Instead, set the error state directly - if (e instanceof Error) { - setError(e); - } - } - } - } - } - } catch (e) { - if (e instanceof Error && e.name !== 'AbortError') { - console.error('Error reading SSE stream:', e); - if (onError) { - onError(e); - } - // Re-throw the error so it gets caught by sendRequest and sets the error state - throw e; - } - } finally { - reader.releaseLock(); - } - - return currentMessages; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mutate, mutateChatState, onFinish, onError, forceUpdate, setError] - ); - - // Send a request to the server - const sendRequest = useCallback( - async (requestMessages: Message[]) => { - try { - mutateChatState(ChatState.Thinking); // Start in thinking state - setError(undefined); - - // Create abort controller - const abortController = new AbortController(); - abortControllerRef.current = abortController; - - // Send request to the server - const response = await fetch(api, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'X-Secret-Key': await window.electron.getSecretKey(), - ...extraMetadataRef.current.headers, - }, - body: JSON.stringify({ - messages: requestMessages, - ...extraMetadataRef.current.body, - }), - signal: abortController.signal, - }); - - if (onResponse) { - await onResponse(response); - } - - if (!response.ok) { - const text = await response.text(); - throw new Error(text || `Error ${response.status}: ${response.statusText}`); - } - - // Process the SSE stream - const updatedMessages = await processMessageStream(response, requestMessages); - - // Auto-submit when all tool calls in the last assistant message have results - if (maxSteps > 1 && updatedMessages.length > requestMessages.length) { - const lastMessage = updatedMessages[updatedMessages.length - 1]; - if (lastMessage.role === 'assistant' && hasCompletedToolCalls(lastMessage)) { - // Count trailing assistant messages to prevent infinite loops - let assistantCount = 0; - for (let i = updatedMessages.length - 1; i >= 0; i--) { - if (updatedMessages[i].role === 'assistant') { - assistantCount++; - } else { - break; - } - } - - if (assistantCount < maxSteps) { - await sendRequest(updatedMessages); - } - } - } - - abortControllerRef.current = null; - } catch (err) { - // Ignore abort errors as they are expected - if (err instanceof Error && err.name === 'AbortError') { - abortControllerRef.current = null; - return; - } - - if (onError && err instanceof Error) { - onError(err); - } - - setError(err as Error); - } finally { - // Check if the last message has pending tool confirmations - const currentMessages = messagesRef.current; - const lastMessage = currentMessages[currentMessages.length - 1]; - const hasPendingToolConfirmation = lastMessage?.content.some( - (content) => content.type === 'toolConfirmationRequest' - ); - - if (hasPendingToolConfirmation) { - mutateChatState(ChatState.WaitingForUserInput); - } else { - mutateChatState(ChatState.Idle); - } - } - }, - - [api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps] - ); - - // Append a new message and send request - const append = useCallback( - async (message: Message | string) => { - // If a string is passed, convert it to a Message object - const messageToAppend = typeof message === 'string' ? createUserMessage(message) : message; - - // If we were waiting for user input and user provides input, transition away from that state - if (chatState === ChatState.WaitingForUserInput) { - mutateChatState(ChatState.Thinking); - } - - const currentMessages = [...messagesRef.current, messageToAppend]; - mutate(currentMessages, false); - await sendRequest(currentMessages); - }, - [mutate, sendRequest, chatState, mutateChatState] - ); - - // Reload the last message - const reload = useCallback(async () => { - const currentMessages = messagesRef.current; - if (currentMessages.length === 0) { - return; - } - - // Remove last assistant message if present - const lastMessage = currentMessages[currentMessages.length - 1]; - const messagesToSend = - lastMessage.role === 'assistant' ? currentMessages.slice(0, -1) : currentMessages; - - await sendRequest(messagesToSend); - }, [sendRequest]); - - // Stop the current request - const stop = useCallback(() => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - abortControllerRef.current = null; - } - }, []); - - // Set messages directly - const setMessages = useCallback( - (messagesOrFn: Message[] | ((messages: Message[]) => Message[])) => { - if (typeof messagesOrFn === 'function') { - const newMessages = messagesOrFn(messagesRef.current); - mutate(newMessages, false); - messagesRef.current = newMessages; - } else { - mutate(messagesOrFn, false); - messagesRef.current = messagesOrFn; - } - }, - [mutate] - ); - - // Input state and handlers - const [input, setInput] = useState(initialInput); - - const handleInputChange = useCallback( - (e: React.ChangeEvent | React.ChangeEvent) => { - setInput(e.target.value); - }, - [] - ); - - const handleSubmit = useCallback( - async (event?: { preventDefault?: () => void }) => { - event?.preventDefault?.(); - if (!input.trim()) return; - - await append(input); - setInput(''); - }, - [input, append] - ); - - // Add tool result to a message - const addToolResult = useCallback( - ({ toolCallId, result }: { toolCallId: string; result: unknown }) => { - const currentMessages = messagesRef.current; - - // Find the last assistant message with the tool call - let lastAssistantIndex = -1; - for (let i = currentMessages.length - 1; i >= 0; i--) { - if (currentMessages[i].role === 'assistant') { - const toolRequests = currentMessages[i].content.filter( - (content) => content.type === 'toolRequest' && content.id === toolCallId - ); - if (toolRequests.length > 0) { - lastAssistantIndex = i; - break; - } - } - } - - if (lastAssistantIndex === -1) return; - - // Create a tool response message - const toolResponseMessage: Message = { - id: generateMessageId(), - role: 'user' as const, - created: Math.floor(Date.now() / 1000), - metadata: { userVisible: true, agentVisible: true }, - content: [ - { - type: 'toolResponse' as const, - id: toolCallId, - toolResult: { - status: 'success' as const, - value: Array.isArray(result) - ? result - : [{ type: 'text' as const, text: String(result), priority: 0 }], - }, - }, - ], - }; - - // Insert the tool response after the assistant message - const updatedMessages = [ - ...currentMessages.slice(0, lastAssistantIndex + 1), - toolResponseMessage, - ...currentMessages.slice(lastAssistantIndex + 1), - ]; - - mutate(updatedMessages, false); - messagesRef.current = updatedMessages; - - // Auto-submit if we have tool results - if (maxSteps > 1) { - sendRequest(updatedMessages); - } - }, - [mutate, maxSteps, sendRequest] - ); - - return { - messages: messages || [], - error, - append, - reload, - stop, - setMessages, - input, - setInput, - handleInputChange, - handleSubmit, - chatState, - addToolResult, - updateMessageStreamBody, - notifications, - currentModelInfo, - session, - setError, - tokenState, - }; -} diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 24dfe2f50f..bf01f3848d 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -47,7 +47,6 @@ import { updateTrayMenu, } from './utils/autoUpdater'; import { UPDATES_ENABLED } from './updates'; -import { Recipe } from './recipe'; import './utils/recipeHash'; import { Client, createClient, createConfig } from './api/client'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; @@ -192,9 +191,9 @@ if (process.platform !== 'darwin') { undefined, undefined, undefined, - undefined, recipeDeeplink || undefined, - scheduledJobId || undefined + scheduledJobId || undefined, + undefined ); }); return; // Skip the rest of the handler @@ -280,7 +279,7 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { } else if (parsedUrl.hostname === 'sessions') { window.webContents.send('open-shared-session', pendingDeepLink); } else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') { - const recipeDeeplink = parsedUrl.searchParams.get('config'); + const recipeDeeplink = parseRecipeDeeplink(parsedUrl.toString()); const scheduledJobId = parsedUrl.searchParams.get('scheduledJob'); // Create a new window and ignore the passed-in window @@ -291,12 +290,12 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) { undefined, undefined, undefined, - undefined, recipeDeeplink || undefined, - scheduledJobId || undefined + scheduledJobId || undefined, + undefined ); + pendingDeepLink = null; } - pendingDeepLink = null; } let windowDeeplinkURL: string | null = null; @@ -325,9 +324,9 @@ app.on('open-url', async (_event, url) => { undefined, undefined, undefined, - undefined, recipeDeeplink || undefined, - scheduledJobId || undefined + scheduledJobId || undefined, + undefined ); windowDeeplinkURL = null; return; // Skip the rest of the handler @@ -350,7 +349,6 @@ app.on('open-url', async (_event, url) => { } else if (parsedUrl.hostname === 'sessions') { firstOpenWindow.webContents.send('open-shared-session', pendingDeepLink); } - pendingDeepLink = null; } }); @@ -408,8 +406,8 @@ async function handleFileOpen(filePath: string) { newWindow.focus(); newWindow.moveTop(); } - } catch { - console.error('Failed to handle file open'); + } catch (error) { + console.error('Failed to handle file open:', error); // Show user-friendly error notification new Notification({ @@ -494,9 +492,8 @@ const createChat = async ( dir?: string, _version?: string, resumeSessionId?: string, - recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink viewType?: string, - recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding. + recipeDeeplink?: string, // Raw deeplink decoded on server scheduledJobId?: string, // Scheduled job ID if applicable recipeId?: string ) => { @@ -693,10 +690,7 @@ const createChat = async ( } if ( appPath === '/' && - (recipe !== undefined || - recipeDeeplink !== undefined || - recipeId !== undefined || - initialMessage) + (recipeDeeplink !== undefined || recipeId !== undefined || initialMessage) ) { appPath = '/pair'; } @@ -708,6 +702,14 @@ const createChat = async ( appPath = '/pair'; } } + // Only add recipeId to URL for the non-deeplink case (saved recipes launched from UI) + // For deeplinks, the recipe object is passed via appConfig, not URL params + if (recipeId) { + searchParams.set('recipeId', recipeId); + if (appPath === '/') { + appPath = '/pair'; + } + } // Goose's react app uses HashRouter, so the path + search params follow a #/ url.hash = `${appPath}?${searchParams.toString()}`; @@ -1041,15 +1043,14 @@ function parseRecipeDeeplink(url: string): string | undefined { // URLSearchParams decodes + as space, which can break encoded configs // Parse raw query to preserve "+" characters in values like config const search = parsedUrl.search || ''; - // parse recipe deeplink from search params const configMatch = search.match(/(?:[?&])config=([^&]*)/); - // get recipe deeplink from config match let recipeDeeplinkTmp = configMatch ? configMatch[1] : null; if (recipeDeeplinkTmp) { try { recipeDeeplink = decodeURIComponent(recipeDeeplinkTmp); - } catch { - // Leave as-is if decoding fails + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + console.error('[Main] parseRecipeDeeplink - Failed to decode:', errorMessage); return undefined; } } @@ -2054,22 +2055,18 @@ async function appMain() { ipcMain.on( 'create-chat-window', - (_, query, dir, version, resumeSessionId, recipe, viewType, recipeId) => { + (_, query, dir, version, resumeSessionId, viewType, recipeId) => { if (!dir?.trim()) { const recentDirs = loadRecentDirs(); dir = recentDirs.length > 0 ? recentDirs[0] : undefined; } - // Log the recipe for debugging - console.log('Creating chat window with recipe:', recipe); - createChat( app, query, dir, version, resumeSessionId, - recipe, viewType, undefined, undefined, @@ -2085,7 +2082,7 @@ async function appMain() { } }); - ipcMain.on('notify', (_event, data) => { + ipcMain.on('notify', (event, data) => { try { // Validate notification data if (!data || typeof data !== 'object') { @@ -2110,10 +2107,24 @@ async function appMain() { const sanitizeText = (text: string) => text.replace(/<[^>]*>/g, ''); console.log('NOTIFY', data); - new Notification({ + const notification = new Notification({ title: sanitizeText(data.title), body: sanitizeText(data.body), - }).show(); + }); + + // Add click handler to focus the window + notification.on('click', () => { + const window = BrowserWindow.fromWebContents(event.sender); + if (window) { + if (window.isMinimized()) { + window.restore(); + } + window.show(); + window.focus(); + } + }); + + notification.show(); } catch (error) { console.error('Error showing notification:', error); } @@ -2163,39 +2174,6 @@ async function appMain() { } }); - ipcMain.handle('start-power-save-blocker', (event) => { - const window = BrowserWindow.fromWebContents(event.sender); - const windowId = window?.id; - - if (windowId && !windowPowerSaveBlockers.has(windowId)) { - const blockerId = powerSaveBlocker.start('prevent-app-suspension'); - windowPowerSaveBlockers.set(windowId, blockerId); - console.log(`[Main] Started power save blocker ${blockerId} for window ${windowId}`); - return true; - } - - if (windowId && windowPowerSaveBlockers.has(windowId)) { - console.log(`[Main] Power save blocker already active for window ${windowId}`); - } - - return false; - }); - - ipcMain.handle('stop-power-save-blocker', (event) => { - const window = BrowserWindow.fromWebContents(event.sender); - const windowId = window?.id; - - if (windowId && windowPowerSaveBlockers.has(windowId)) { - const blockerId = windowPowerSaveBlockers.get(windowId)!; - powerSaveBlocker.stop(blockerId); - windowPowerSaveBlockers.delete(windowId); - console.log(`[Main] Stopped power save blocker ${blockerId} for window ${windowId}`); - return true; - } - - return false; - }); - // Handle metadata fetching from main process ipcMain.handle('fetch-metadata', async (_event, url) => { try { diff --git a/ui/desktop/src/preload.ts b/ui/desktop/src/preload.ts index 20a0d95b74..2940f9b508 100644 --- a/ui/desktop/src/preload.ts +++ b/ui/desktop/src/preload.ts @@ -52,7 +52,6 @@ type ElectronAPI = { dir?: string, version?: string, resumeSessionId?: string, - recipe?: Recipe, viewType?: string, recipeId?: string ) => void; @@ -64,8 +63,7 @@ type ElectronAPI = { reloadApp: () => void; checkForOllama: () => Promise; selectFileOrDirectory: (defaultPath?: string) => Promise; - startPowerSaveBlocker: () => Promise; - stopPowerSaveBlocker: () => Promise; + getBinaryPath: (binaryName: string) => Promise; readFile: (directory: string) => Promise; writeFile: (directory: string, content: string) => Promise; ensureDirectory: (dirPath: string) => Promise; @@ -143,7 +141,6 @@ const electronAPI: ElectronAPI = { dir?: string, version?: string, resumeSessionId?: string, - recipe?: Recipe, viewType?: string, recipeId?: string ) => @@ -153,7 +150,6 @@ const electronAPI: ElectronAPI = { dir, version, resumeSessionId, - recipe, viewType, recipeId ), @@ -166,8 +162,7 @@ const electronAPI: ElectronAPI = { checkForOllama: () => ipcRenderer.invoke('check-ollama'), selectFileOrDirectory: (defaultPath?: string) => ipcRenderer.invoke('select-file-or-directory', defaultPath), - startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'), - stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'), + getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName), readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index ecbcb691b8..5217dd0f42 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -2,48 +2,52 @@ import { Session, startAgent } from './api'; import type { setViewType } from './hooks/useNavigation'; export function resumeSession(session: Session, setView: setViewType) { - if (process.env.ALPHA) { - setView('pair', { - disableAnimation: true, - resumeSessionId: session.id, - }); - } else { - const workingDir = session.working_dir; - if (!workingDir) { - throw new Error('Cannot resume session: working directory is missing in session'); - } - window.electron.createChatWindow( - undefined, // query - workingDir, - undefined, // version - session.id - ); + setView('pair', { + disableAnimation: true, + resumeSessionId: session.id, + }); +} + +export async function createSession(options?: { + recipeId?: string; + recipeDeeplink?: string; +}): Promise { + const body: { + working_dir: string; + recipe_id?: string; + recipe_deeplink?: string; + } = { + working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, + }; + + if (options?.recipeId) { + body.recipe_id = options.recipeId; + } else if (options?.recipeDeeplink) { + body.recipe_deeplink = options.recipeDeeplink; } + + const newAgent = await startAgent({ + body, + throwOnError: true, + }); + return newAgent.data; } export async function startNewSession( initialText: string | undefined, - resetChat: (() => void) | null, - setView: setViewType -) { - if (!resetChat || process.env.ALPHA) { - const newAgent = await startAgent({ - body: { - working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string, - }, - throwOnError: true, - }); - const session = newAgent.data; - setView('pair', { - disableAnimation: true, - initialMessage: initialText, - resumeSessionId: session.id, - }); - } else { - resetChat(); - setView('pair', { - disableAnimation: true, - initialMessage: initialText, - }); + setView: setViewType, + options?: { + recipeId?: string; + recipeDeeplink?: string; } +): Promise { + const session = await createSession(options); + + setView('pair', { + disableAnimation: true, + initialMessage: initialText, + resumeSessionId: session.id, + }); + + return session; } diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index 4f8849c828..d75cb12506 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -182,7 +182,7 @@ function ToastErrorContent({
{showRecovery ? ( - + ) : traceback ? ( ) : null} diff --git a/ui/desktop/src/types/message.ts b/ui/desktop/src/types/message.ts index 1c433f88d1..8c2cc4125f 100644 --- a/ui/desktop/src/types/message.ts +++ b/ui/desktop/src/types/message.ts @@ -1,7 +1,8 @@ -import { Message, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api'; +import { Message, MessageEvent, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api'; export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' }; export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' }; +export type NotificationEvent = Extract; // Compaction response message - must match backend constant const COMPACTION_THINKING_TEXT = 'goose is compacting the conversation...'; @@ -16,25 +17,6 @@ export function createUserMessage(text: string): Message { }; } -export function createToolErrorResponseMessage(id: string, error: string): Message { - return { - id: generateMessageId(), - role: 'user', - created: Math.floor(Date.now() / 1000), - content: [ - { - type: 'toolResponse', - id, - toolResult: { - status: 'error', - error, - }, - }, - ], - metadata: { userVisible: true, agentVisible: true }, - }; -} - export function generateMessageId(): string { return Math.random().toString(36).substring(2, 10); }