Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Zane <75694352+zanesq@users.noreply.github.com>
Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
Douwe Osinga 2025-10-21 18:00:21 -04:00 committed by GitHub
parent 5076481092
commit d836a10af1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 671 additions and 477 deletions

View file

@ -170,13 +170,14 @@ run-ui-only:
@echo "Running UI..."
cd ui/desktop && npm install && npm run start-gui
debug-ui:
@echo "🚀 Starting goose frontend in external backend mode"
cd ui/desktop && \
export GOOSE_EXTERNAL_BACKEND=true && \
export GOOSE_EXTERNAL_PORT=3000 && \
npm install && \
npm run start-gui
debug-ui *alpha:
@echo "🚀 Starting goose frontend in external backend mode{{ if alpha == "alpha" { " with alpha features enabled" } else { "" } }}"
cd ui/desktop && \
export GOOSE_EXTERNAL_BACKEND=true && \
export GOOSE_EXTERNAL_PORT=3000 && \
{{ if alpha == "alpha" { "export ALPHA=true &&" } else { "" } }} \
npm install && \
npm run {{ if alpha == "alpha" { "start-alpha-gui" } else { "start-gui" } }}
# Run UI with main process debugging enabled
# To debug main process:

View file

@ -14,7 +14,7 @@ use goose::config::PermissionManager;
use goose::config::Config;
use goose::model::ModelConfig;
use goose::prompt_template::render_global_file;
use goose::providers::create;
use goose::providers::{create, create_with_named_model};
use goose::recipe::Recipe;
use goose::recipe_deeplink;
use goose::session::{Session, SessionManager};
@ -28,7 +28,7 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::Ordering;
use std::sync::Arc;
use tracing::error;
use tracing::{error, warn};
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateFromSessionRequest {
@ -67,6 +67,7 @@ pub struct StartAgentRequest {
#[derive(Deserialize, utoipa::ToSchema)]
pub struct ResumeAgentRequest {
session_id: String,
load_model_and_extensions: bool,
}
#[utoipa::path(
@ -172,6 +173,7 @@ async fn start_agent(
)
)]
async fn resume_agent(
State(state): State<Arc<AppState>>,
Json(payload): Json<ResumeAgentRequest>,
) -> Result<Json<Session>, ErrorResponse> {
let session = SessionManager::get_session(&payload.session_id, true)
@ -184,6 +186,74 @@ async fn resume_agent(
}
})?;
if payload.load_model_and_extensions {
let agent = state
.get_agent_for_route(payload.session_id)
.await
.map_err(|code| ErrorResponse {
message: "Failed to get agent for route".into(),
status: code,
})?;
let config = Config::global();
let provider_result = async {
let provider_name: String =
config
.get_param("GOOSE_PROVIDER")
.map_err(|_| ErrorResponse {
message: "Could not configure agent: missing provider".into(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let model: String = config.get_param("GOOSE_MODEL").map_err(|_| ErrorResponse {
message: "Could not configure agent: missing model".into(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
let provider = create_with_named_model(&provider_name, &model)
.await
.map_err(|_| ErrorResponse {
message: "Could not configure agent: missing model".into(),
status: StatusCode::INTERNAL_SERVER_ERROR,
})?;
agent
.update_provider(provider)
.await
.map_err(|e| ErrorResponse {
message: format!("Could not configure agent: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
})
};
let extensions_result = async {
let enabled_configs = goose::config::get_enabled_extensions();
let agent_clone = agent.clone();
let extension_futures = enabled_configs
.into_iter()
.map(|config| {
let config_clone = config.clone();
let agent_ref = agent_clone.clone();
async move {
if let Err(e) = agent_ref.add_extension(config_clone.clone()).await {
warn!("Failed to load extension {}: {}", config_clone.name(), e);
}
Ok::<_, ErrorResponse>(())
}
})
.collect::<Vec<_>>();
futures::future::join_all(extension_futures).await;
Ok::<(), ErrorResponse>(()) // Fixed type annotation
};
let (provider_result, _) = tokio::join!(provider_result, extensions_result);
provider_result?;
}
Ok(Json(session))
}

View file

@ -63,9 +63,7 @@ pub struct SessionUpdateBuilder {
#[derive(Serialize, ToSchema, Debug)]
#[serde(rename_all = "camelCase")]
pub struct SessionInsights {
/// Total number of sessions
total_sessions: usize,
/// Total tokens used across all sessions
total_tokens: i64,
}
@ -785,7 +783,9 @@ impl SessionStorage {
.await?;
let mut messages = Vec::new();
for (role_str, content_json, created_timestamp, metadata_json) in rows {
for (idx, (role_str, content_json, created_timestamp, metadata_json)) in
rows.into_iter().enumerate()
{
let role = match role_str.as_str() {
"user" => Role::User,
"assistant" => Role::Assistant,
@ -799,6 +799,8 @@ impl SessionStorage {
let mut message = Message::new(role, created_timestamp, content);
message.metadata = metadata;
// TODO(Douwe): make id required
message = message.with_id(format!("msg_{}_{}", session_id, idx));
messages.push(message);
}

View file

@ -3794,9 +3794,13 @@
"ResumeAgentRequest": {
"type": "object",
"required": [
"session_id"
"session_id",
"load_model_and_extensions"
],
"properties": {
"load_model_and_extensions": {
"type": "boolean"
},
"session_id": {
"type": "string"
}
@ -4122,13 +4126,11 @@
"properties": {
"totalSessions": {
"type": "integer",
"description": "Total number of sessions",
"minimum": 0
},
"totalTokens": {
"type": "integer",
"format": "int64",
"description": "Total tokens used across all sessions"
"format": "int64"
}
}
},

View file

@ -89,18 +89,32 @@ const PairRouteWrapper = ({
const setView = useNavigation();
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const [initialMessage] = useState(routeState.initialMessage);
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
// Determine which session ID to use:
// 1. From route state (when navigating from Hub with a new session)
// 2. From URL params (when resuming a session)
// 3. From the existing chat state (when navigating to Pair directly)
const sessionId = routeState.resumeSessionId || resumeSessionId || chat.sessionId;
// Update URL with session ID if it's not already there (new chat from pair)
useEffect(() => {
if (process.env.ALPHA && sessionId && sessionId !== resumeSessionId) {
setSearchParams((prev) => {
prev.set('resumeSessionId', sessionId);
return prev;
});
}
}, [sessionId, resumeSessionId, setSearchParams]);
return process.env.ALPHA ? (
<Pair2
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
sessionId={sessionId}
initialMessage={initialMessage}
/>
) : (

View file

@ -605,6 +605,7 @@ export type Response = {
};
export type ResumeAgentRequest = {
load_model_and_extensions: boolean;
session_id: string;
};
@ -707,13 +708,7 @@ export type SessionDisplayInfo = {
};
export type SessionInsights = {
/**
* Total number of sessions
*/
totalSessions: number;
/**
* Total tokens used across all sessions
*/
totalTokens: number;
};

View file

@ -1,37 +0,0 @@
interface AgentHeaderProps {
title: string;
profileInfo?: string;
onChangeProfile?: () => void;
showBorder?: boolean;
}
export function AgentHeader({
title,
profileInfo,
onChangeProfile,
showBorder = false,
}: AgentHeaderProps) {
return (
<div
className={`flex items-center justify-between px-4 py-2 ${showBorder ? 'border-b border-borderSubtle' : ''}`}
>
<div className="flex items-center ml-6">
<span className="w-2 h-2 rounded-full bg-green-500 mr-2" />
<span className="text-sm">
<span className="text-textSubtle">Agent</span>{' '}
<span className="text-textStandard">{title}</span>
</span>
</div>
{profileInfo && (
<div className="flex items-center text-sm">
<span className="text-textSubtle">{profileInfo}</span>
{onChangeProfile && (
<button onClick={onChangeProfile} className="ml-2 text-blockTeal hover:underline">
change profile
</button>
)}
</div>
)}
</div>
);
}

View file

@ -44,7 +44,7 @@
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
import { RecipeHeader } from './RecipeHeader';
import LoadingGoose from './LoadingGoose';
import RecipeActivities from './recipes/RecipeActivities';
import PopularChatTopics from './PopularChatTopics';
@ -315,16 +315,7 @@ function BaseChatContent({
{/* Recipe agent header - sticky at top of chat container */}
{recipe?.title && (
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
<AgentHeader
title={recipe.title}
profileInfo={
recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined
}
onChangeProfile={() => {
console.log('Change profile clicked');
}}
showBorder={true}
/>
<RecipeHeader title={recipe.title} />
</div>
)}

View file

@ -1,172 +1,121 @@
import React, { useEffect, useRef, useState } from 'react';
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 { View, ViewOptions } from '../utils/navigationUtils';
import { ContextManagerProvider } from './context_management/ContextManager';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { useFileDrop } from '../hooks/useFileDrop';
import { Message, Session } from '../api';
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 { loadSession } from '../utils/sessionCache';
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';
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<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string;
disableSearch?: boolean;
showPopularTopics?: boolean;
suppressEmptyState?: boolean;
autoSubmit?: boolean;
resumeSessionId?: string; // Optional session ID to resume on mount
suppressEmptyState: boolean;
sessionId: string;
initialMessage?: string;
}
function BaseChatContent({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
renderHeader,
renderBeforeMessages,
renderAfterMessages,
customChatInputProps = {},
customMainLayoutProps = {},
disableSearch = false,
resumeSessionId,
sessionId,
initialMessage,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
const disableAnimation = location.state?.disableAnimation || false;
// const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
// const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
// const { isCompacting, handleManualCompaction } = useContextManager();
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasAcceptedRecipe, setHasAcceptedRecipe] = useState<boolean>();
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();
// Use shared cost tracking
// const { sessionCosts } = useCostTracking({
// sessionInputTokens,
// sessionOutputTokens,
// localInputTokens,
// localOutputTokens,
// session: sessionMetadata,
// });
const onStreamFinish = useCallback(() => {}, []);
// Session loading state
const [sessionLoadError, setSessionLoadError] = useState<string | null>(null);
const hasLoadedSessionRef = useRef(false);
const [messages, setMessages] = useState(chat.messages || []);
// Load session on mount if resumeSessionId is provided
useEffect(() => {
const needsLoad = resumeSessionId && !hasLoadedSessionRef.current;
if (needsLoad) {
hasLoadedSessionRef.current = true;
setSessionLoadError(null);
// Set chat to empty session to indicate loading state
// todo: set to null instead and handle that in other places
const emptyChat: ChatType = {
sessionId: resumeSessionId,
title: 'Loading...',
messageHistoryIndex: 0,
messages: [],
recipe: null,
recipeParameterValues: null,
};
setChat(emptyChat);
loadSession(resumeSessionId)
.then((session: Session) => {
const conversation = session.conversation || [];
const loadedChat: ChatType = {
sessionId: session.id,
title: session.description || 'Untitled Chat',
messageHistoryIndex: 0,
messages: conversation,
recipe: null,
recipeParameterValues: null,
};
setChat(loadedChat);
})
.catch((error: Error) => {
const errorMessage = error.message || 'Failed to load session';
setSessionLoadError(errorMessage);
});
}
}, [resumeSessionId, setChat]);
// Update messages when chat changes (e.g., when resuming a session)
useEffect(() => {
if (chat.messages) {
setMessages(chat.messages);
}
}, [chat.messages, chat.sessionId]);
const { chatState, handleSubmit, stopStreaming } = useChatStream({
sessionId: chat.sessionId || '',
messages,
setMessages,
onStreamFinish: () => {},
});
const { session, messages, chatState, handleSubmit, stopStreaming, sessionLoadError } =
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);
// }
//
// if (onMessageSubmit && textValue.trim()) {
// onMessageSubmit(textValue);
// }
if (recipe && textValue.trim()) {
setHasStartedUsingRecipe(true);
}
handleSubmit(textValue);
};
// TODO(Douwe): send this to the chatbox instead, possibly autosubmit? or backend
const append = (_txt: string) => {};
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(() => {
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!recipe) return;
(async () => {
const accepted = await window.electron.hasAcceptedRecipeBefore(recipe);
setHasAcceptedRecipe(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);
setHasAcceptedRecipe(true);
} else {
setView('chat');
}
};
// Track if this is the initial render for session resuming
const initialRenderRef = useRef(true);
const recipe = chat?.recipe;
// Auto-scroll when messages are loaded (for session resuming)
const handleRenderingComplete = React.useCallback(() => {
// Only force scroll on the very first render
@ -182,16 +131,7 @@ function BaseChatContent({
}
}, [messages.length]);
//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(() => {
@ -226,11 +166,29 @@ function BaseChatContent({
</>
);
const showPopularTopics = messages.length === 0;
const showPopularTopics =
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
// TODO(Douwe): get this from the backend
const isCompacting = false;
const chat: ChatType = {
messageHistoryIndex: 0,
messages,
recipe,
sessionId,
title: session?.description || 'No Session',
};
const initialPrompt = messages.length == 0 && recipe?.prompt ? recipe.prompt : '';
// Map chatState to LoadingGoose message
const getLoadingMessage = (): string | undefined => {
if (isCompacting) return 'goose is compacting the conversation...';
if (messages.length === 0 && chatState === ChatState.Thinking) {
return 'loading conversation...';
}
return undefined;
};
return (
<div className="h-full flex flex-col min-h-0">
<h2>Warning: BaseChat2!</h2>
@ -254,36 +212,22 @@ function BaseChatContent({
paddingX={6}
paddingY={0}
>
{/*/!* Recipe agent header - sticky at top of chat container *!/*/}
{/*{recipe?.title && (*/}
{/* <div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">*/}
{/* <AgentHeader*/}
{/* title={recipe.title}*/}
{/* profileInfo={*/}
{/* recipe.profile ? `${recipe.profile} - ${recipe.mcps || 12} MCPs` : undefined*/}
{/* }*/}
{/* onChangeProfile={() => {*/}
{/* console.log('Change profile clicked');*/}
{/* }}*/}
{/* showBorder={true}*/}
{/* />*/}
{/* </div>*/}
{/*)}*/}
{recipe?.title && (
<div className="sticky top-0 z-10 bg-background-default px-0 -mx-6 mb-6 pt-6">
<RecipeHeader title={recipe.title} />
</div>
)}
{/* Custom content before messages */}
{renderBeforeMessages && renderBeforeMessages()}
{/*/!* Recipe Activities - always show when recipe is active and accepted *!/*/}
{/*{recipe && recipeAccepted && !suppressEmptyState && (*/}
{/* <div className={hasStartedUsingRecipe ? 'mb-6' : ''}>*/}
{/* <RecipeActivities*/}
{/* append={(text: string) => appendWithTracking(text)}*/}
{/* activities={Array.isArray(recipe.activities) ? recipe.activities : null}*/}
{/* title={recipe.title}*/}
{/* parameterValues={recipeParameters || {}}*/}
{/* />*/}
{/* </div>*/}
{/*)}*/}
{recipe && (
<div className={hasStartedUsingRecipe ? 'mb-6' : ''}>
<RecipeActivities
append={(text: string) => handleSubmit(text)}
activities={Array.isArray(recipe.activities) ? recipe.activities : null}
title={recipe.title}
//parameterValues={recipeParameters || {}}
/>
</div>
)}
{sessionLoadError && (
<div className="flex flex-col items-center justify-center p-8">
@ -293,96 +237,30 @@ function BaseChatContent({
</div>
<button
onClick={() => {
setSessionLoadError(null);
hasLoadedSessionRef.current = false;
setView('chat');
}}
className="px-4 py-2 text-center cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-lg transition-all duration-150"
>
Retry
Go home
</button>
</div>
)}
{/* Messages or Popular Topics */}
{
messages.length > 0 || recipe ? (
<>
{disableSearch ? (
renderProgressiveMessageList(chat)
) : (
// Render messages with SearchView wrapper when search is enabled
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
)}
{messages.length > 0 || recipe ? (
<>
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
{/*{error && (*/}
{/* <>*/}
{/* <div className="flex flex-col items-center justify-center p-4">*/}
{/* <div className="text-red-700 dark:text-red-300 bg-red-400/50 p-3 rounded-lg mb-2">*/}
{/* {error.message || 'Honk! Goose experienced an error while responding'}*/}
{/* </div>*/}
{/* /!* Action buttons for all errors including token limit errors *!/*/}
{/* <div className="flex gap-2 mt-2">*/}
{/* <div*/}
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
{/* onClick={async () => {*/}
{/* clearError();*/}
{/* await handleManualCompaction(*/}
{/* messages,*/}
{/* setMessages,*/}
{/* append,*/}
{/* chat.sessionId*/}
{/* );*/}
{/* }}*/}
{/* >*/}
{/* Summarize Conversation*/}
{/* </div>*/}
{/* <div*/}
{/* className="px-3 py-2 text-center whitespace-nowrap cursor-pointer text-textStandard border border-borderSubtle hover:bg-bgSubtle rounded-full inline-block transition-all duration-150"*/}
{/* onClick={async () => {*/}
{/* // 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*/}
{/* </div>*/}
{/* </div>*/}
{/* </div>*/}
{/* </>*/}
{/*)}*/}
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
/* Show PopularChatTopics when no messages, no recipe, and showPopularTopics is true (Pair view) */
<PopularChatTopics append={(text: string) => append(text)} />
) : null /* Show nothing when messages.length === 0 && suppressEmptyState === true */
}
{/* Custom content after messages */}
{renderAfterMessages && renderAfterMessages()}
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
<PopularChatTopics append={(text: string) => handleSubmit(text)} />
) : null}
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{(messages.length === 0 || isCompacting) && !sessionLoadError && (
{(chatState !== ChatState.Idle || isCompacting) && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
messages.length === 0
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
: undefined
}
chatState={chatState}
/>
<LoadingGoose message={getLoadingMessage()} chatState={chatState} />
</div>
)}
</div>
@ -391,48 +269,46 @@ function BaseChatContent({
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
sessionId={chat?.sessionId || ''}
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
onStop={stopStreaming}
//commandHistory={commandHistory}
initialValue={initialPrompt}
setView={setView}
// numTokens={sessionTokenCount}
// inputTokens={sessionInputTokens || localInputTokens}
// outputTokens={sessionOutputTokens || localOutputTokens}
numTokens={session?.total_tokens || undefined}
inputTokens={session?.input_tokens || undefined}
outputTokens={session?.output_tokens || undefined}
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages}
setMessages={(_m) => {}}
disableAnimation={disableAnimation}
//sessionCosts={sessionCosts}
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
//recipeAccepted={recipeAccepted}
recipeAccepted={hasAcceptedRecipe}
initialPrompt={initialPrompt}
//toolCount={toolCount || 0}
toolCount={0}
//autoSubmit={autoSubmit}
toolCount={toolCount || 0}
autoSubmit={false}
//append={append}
{...customChatInputProps}
/>
</div>
</MainPanelLayout>
{/*/!* Recipe Warning Modal *!/*/}
{/*<RecipeWarningModal*/}
{/* isOpen={isRecipeWarningModalOpen}*/}
{/* onConfirm={handleRecipeAccept}*/}
{/* onCancel={handleRecipeCancel}*/}
{/* recipeDetails={{*/}
{/* title: recipe?.title,*/}
{/* description: recipe?.description,*/}
{/* instructions: recipe?.instructions || undefined,*/}
{/* }}*/}
{/* hasSecurityWarnings={hasSecurityWarnings}*/}
{/*/>*/}
{recipe && (
<RecipeWarningModal
isOpen={!hasAcceptedRecipe}
onConfirm={() => handleRecipeAccept(true)}
onCancel={() => handleRecipeAccept(false)}
recipeDetails={{
title: recipe.title,
description: recipe.description,
instructions: recipe.instructions || undefined,
}}
hasSecurityWarnings={hasRecipeSecurityWarnings}
/>
)}
{/*/!* Recipe Parameter Modal *!/*/}
{/*{isParameterModalOpen && filteredParameters.length > 0 && (*/}

View file

@ -1,35 +1,28 @@
import { View, ViewOptions } from '../utils/navigationUtils';
import 'react-toastify/dist/ReactToastify.css';
import { ChatType } from '../types/chat';
import BaseChat2 from './BaseChat2';
export interface PairRouteState {
resumeSessionId?: string;
interface PairProps {
setChat: (chat: ChatType) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
sessionId: string;
initialMessage?: string;
}
interface PairProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}
export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
resumeSessionId,
}: PairProps & PairRouteState) {
sessionId,
initialMessage,
}: PairProps) {
return (
<BaseChat2
chat={chat}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
sessionId={sessionId}
initialMessage={initialMessage}
suppressEmptyState={false}
/>
);
}

View file

@ -0,0 +1,17 @@
interface RecipeHeaderProps {
title: string;
}
export function RecipeHeader({ title }: RecipeHeaderProps) {
return (
<div className={`flex items-center justify-between px-4 py-2 border-b border-borderSubtle'}`}>
<div className="flex items-center ml-6">
<span className="w-2 h-2 rounded-full bg-green-500 mr-2" />
<span className="text-sm">
<span className="text-textSubtle">Recipe</span>{' '}
<span className="text-textStandard">{title}</span>
</span>
</div>
</div>
);
}

View file

@ -1,35 +1,22 @@
import { useState, useEffect } from 'react';
import { getTools } from '../../api';
const { clearTimeout } = window;
// TODO(Douwe): return this as part of the start agent request
export const useToolCount = (sessionId: string) => {
const [toolCount, setToolCount] = useState<number | null>(null);
useEffect(() => {
let timeoutId: ReturnType<typeof setTimeout>;
const fetchTools = async () => {
try {
const response = await getTools({ query: { session_id: sessionId } });
if (!response.error && response.data) {
setToolCount(response.data.length);
} else {
setToolCount(0);
}
setToolCount(response.error || !response.data ? 0 : response.data.length);
} catch (err) {
console.error('Error fetching tools:', err);
setToolCount(0);
}
};
// Add initial 1s delay before first fetch
timeoutId = setTimeout(fetchTools, 1000);
// Cleanup timeouts on unmount
return () => {
clearTimeout(timeoutId);
};
fetchTools();
}, [sessionId]);
return toolCount;

View file

@ -20,6 +20,7 @@ import { ChatState } from '../types/chatState';
import { ContextManagerProvider } from './context_management/ContextManager';
import 'react-toastify/dist/ReactToastify.css';
import { View, ViewOptions } from '../utils/navigationUtils';
import { startAgent } from '../api';
export default function Hub({
setView,
@ -32,22 +33,35 @@ export default function Hub({
isExtensionsLoading: boolean;
resetChat: () => void;
}) {
// Handle chat input submission - create new chat and navigate to pair
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
// Navigate to pair page with the message to be submitted
// Pair will handle creating the new chat session
resetChat();
setView('pair', {
disableAnimation: true,
initialMessage: combinedTextFromInput,
});
if (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: combinedTextFromInput,
resumeSessionId: session.id,
});
} else {
// Navigate to pair page with the message to be submitted
// Pair will handle creating the new chat session
resetChat();
setView('pair', {
disableAnimation: true,
initialMessage: combinedTextFromInput,
});
}
e.preventDefault();
}
e.preventDefault();
};
return (

View file

@ -8,7 +8,7 @@ import { Skeleton } from '../ui/skeleton';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
import { toastSuccess } from '../../toasts';
import { useEscapeKey } from '../../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse } from '../../api';
import { deleteRecipe, RecipeManifestResponse, startAgent } from '../../api';
import ImportRecipeForm, { ImportRecipeButton } from './ImportRecipeForm';
import CreateEditRecipeModal from './CreateEditRecipeModal';
import { generateDeepLink, Recipe } from '../../recipe';
@ -70,30 +70,50 @@ export default function RecipesView() {
}
};
const handleLoadRecipe = async (recipe: Recipe, recipeId: string) => {
try {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
// Needs a fix but commenting out to get prod back up and running.
//
// if (onLoadRecipe) {
// // Use the callback to navigate within the same window
// onLoadRecipe(savedRecipe.recipe);
// } else {
// Fallback to creating a new window (for backwards compatibility)
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
undefined, // view type,
recipeId // recipe id
);
// }
} catch (err) {
console.error('Failed to load recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to load recipe');
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 {
// onLoadRecipe is not working for loading recipes. It looks correct
// but the instructions are not flowing through to the server.
// Needs a fix but commenting out to get prod back up and running.
//
// if (onLoadRecipe) {
// // Use the callback to navigate within the same window
// onLoadRecipe(savedRecipe.recipe);
// } else {
// Fallback to creating a new window (for backwards compatibility)
window.electron.createChatWindow(
undefined, // query
undefined, // dir
undefined, // version
undefined, // resumeSessionId
recipe, // recipe config
undefined, // view type,
recipeId // recipe id
);
// }
} catch (err) {
console.error('Failed to load recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to load recipe');
}
}
};
@ -193,7 +213,7 @@ export default function RecipesView() {
<Button
onClick={(e) => {
e.stopPropagation();
handleLoadRecipe(recipe, recipeManifestResponse.id);
handleStartRecipeChat(recipe, recipeManifestResponse.id);
}}
size="sm"
className="h-8 w-8 p-0"

View file

@ -238,18 +238,10 @@ function recipeToYaml(recipe: Recipe, executionMode: ExecutionMode): string {
});
}
if (recipe.goosehints) {
cleanRecipe.goosehints = recipe.goosehints;
}
if (recipe.context && recipe.context.length > 0) {
cleanRecipe.context = recipe.context;
}
if (recipe.profile) {
cleanRecipe.profile = recipe.profile;
}
if (recipe.author) {
cleanRecipe.author = {
contact: recipe.author.contact || undefined,

View file

@ -589,7 +589,6 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
);
});
// Render skeleton loader for session items with variations
const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => {
const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2'];
const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24'];

View file

@ -3,6 +3,7 @@ import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
import { useLocation } from 'react-router-dom';
import { getSession, Session } from '../../api';
import { useNavigation } from '../../hooks/useNavigation';
const SessionsView: React.FC = () => {
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
@ -11,6 +12,7 @@ const SessionsView: React.FC = () => {
const [error, setError] = useState<string | null>(null);
const [initialSessionId, setInitialSessionId] = useState<string | null>(null);
const location = useLocation();
const setView = useNavigation();
const loadSessionDetails = async (sessionId: string) => {
setIsLoadingSession(true);
@ -34,9 +36,19 @@ const SessionsView: React.FC = () => {
}
};
const handleSelectSession = useCallback(async (sessionId: string) => {
await loadSessionDetails(sessionId);
}, []);
const handleSelectSession = useCallback(
async (sessionId: string) => {
if (process.env.ALPHA) {
setView('pair', {
disableAnimation: true,
resumeSessionId: sessionId,
});
} else {
await loadSessionDetails(sessionId);
}
},
[setView]
);
// Check if a session ID was passed in the location state (from SessionsInsights)
useEffect(() => {

View file

@ -73,6 +73,7 @@ export function useAgent(): UseAgentReturn {
const agentResponse = await resumeAgent({
body: {
session_id: sessionId,
load_model_and_extensions: false,
},
throwOnError: true,
});
@ -112,6 +113,7 @@ export function useAgent(): UseAgentReturn {
? await resumeAgent({
body: {
session_id: initContext.resumeSessionId,
load_model_and_extensions: false,
},
throwOnError: true,
})

View file

@ -1,15 +1,78 @@
import { useState, useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChatState } from '../types/chatState';
import { Message } from '../api';
import { Conversation, Message, resumeAgent, Session } from '../api';
import { getApiUrl } from '../config';
import { createUserMessage } from '../types/message';
const TextDecoder = globalThis.TextDecoder;
const resultsCache = new Map<string, { messages: Message[]; session: Session }>();
// Debug logging - set to false in production
const DEBUG_CHAT_STREAM = true;
const log = {
session: (action: string, sessionId: string, details?: Record<string, unknown>) => {
if (!DEBUG_CHAT_STREAM) return;
console.log(`[useChatStream:session] ${action}`, {
sessionId: sessionId.slice(0, 8),
...details,
});
},
messages: (action: string, count: number, details?: Record<string, unknown>) => {
if (!DEBUG_CHAT_STREAM) return;
console.log(`[useChatStream:messages] ${action}`, {
count,
...details,
});
},
stream: (action: string, details?: Record<string, unknown>) => {
if (!DEBUG_CHAT_STREAM) return;
console.log(`[useChatStream:stream] ${action}`, details);
},
state: (newState: ChatState, details?: Record<string, unknown>) => {
if (!DEBUG_CHAT_STREAM) return;
console.log(`[useChatStream:state] → ${newState}`, details);
},
error: (context: string, error: unknown) => {
console.error(`[useChatStream:error] ${context}`, error);
},
};
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
interface NotificationEvent {
type: 'Notification';
request_id: string;
message: {
method: string;
params: {
[key: string]: JsonValue;
};
};
}
type MessageEvent =
| { type: 'Message'; message: Message }
| { type: 'Error'; error: string }
| { type: 'Ping' }
| { type: 'Finish'; reason: string }
| { type: 'ModelChange'; model: string; mode: string }
| { type: 'UpdateConversation'; conversation: Conversation }
| NotificationEvent;
interface UseChatStreamProps {
sessionId: string;
onStreamFinish: () => void;
initialMessage?: string;
}
interface UseChatStreamReturn {
session?: Session;
messages: Message[];
setMessages: (messages: Message[]) => void;
onStreamFinish?: () => void;
chatState: ChatState;
handleSubmit: (userMessage: string) => Promise<void>;
stopStreaming: () => void;
sessionLoadError?: string;
}
function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] {
@ -34,33 +97,228 @@ function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[
}
}
async function streamFromResponse(
response: Response,
initialMessages: Message[],
updateMessages: (messages: Message[]) => void,
onFinish: (error?: string) => void
): Promise<void> {
let chunkCount = 0;
let messageEventCount = 0;
try {
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
const reader = response.body.getReader();
const decoder = new TextDecoder();
let currentMessages = initialMessages;
log.stream('reading-chunks');
while (true) {
const { done, value } = await reader.read();
if (done) {
log.stream('chunks-complete', {
totalChunks: chunkCount,
messageEvents: messageEventCount,
});
break;
}
chunkCount++;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const event = JSON.parse(data) as MessageEvent;
switch (event.type) {
case 'Message': {
messageEventCount++;
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
// Only log every 10th message event to avoid spam
if (messageEventCount % 10 === 0) {
log.stream('message-chunk', {
eventCount: messageEventCount,
messageCount: currentMessages.length,
});
}
// This calls the wrapped setMessagesAndLog with 'streaming' context
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);
// This calls the wrapped setMessagesAndLog with 'streaming' context
updateMessages(event.conversation);
break;
}
case 'Notification': {
// Don't log notifications, too noisy
break;
}
case 'Ping': {
// Don't log pings
break;
}
default: {
console.warn('Unhandled event type:', event['type']);
break;
}
}
} catch (e) {
log.error('SSE parse failed', e);
onFinish('Failed to parse SSE:' + e);
}
}
}
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
log.error('stream read error', error);
onFinish('Stream error:' + error);
}
}
}
export function useChatStream({
sessionId,
messages,
setMessages,
onStreamFinish,
}: UseChatStreamProps) {
initialMessage,
}: UseChatStreamProps): UseChatStreamReturn {
const [messages, setMessages] = useState<Message[]>([]);
const messagesRef = useRef<Message[]>([]);
const [session, setSession] = useState<Session>();
const [sessionLoadError, setSessionLoadError] = useState<string>();
const [chatState, setChatState] = useState<ChatState>(ChatState.Idle);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
if (session) {
resultsCache.set(sessionId, { session, messages });
}
}, [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),
});
setMessages(newMessages);
messagesRef.current = newMessages;
}, []);
const onFinish = useCallback(
(error?: string): void => {
if (error) {
setSessionLoadError(error);
}
setChatState(ChatState.Idle);
onStreamFinish();
},
[onStreamFinish]
);
// Load session on mount or sessionId change
useEffect(() => {
if (!sessionId) return;
// Reset state when sessionId changes
log.session('loading', sessionId);
setMessagesAndLog([], 'session-reset');
setSession(undefined);
setSessionLoadError(undefined);
setChatState(ChatState.Thinking);
let cancelled = false;
log.state(ChatState.Thinking, { reason: 'session load start' });
(async () => {
try {
const response = await resumeAgent({
body: {
session_id: sessionId,
load_model_and_extensions: true,
},
throwOnError: true,
});
if (cancelled) return;
const session = response.data;
log.session('loaded', sessionId, {
messageCount: session?.conversation?.length || 0,
description: session?.description,
});
setSession(session);
setMessagesAndLog(session?.conversation || [], 'load-session');
log.state(ChatState.Idle, { reason: 'session load complete' });
setChatState(ChatState.Idle);
} 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' });
setChatState(ChatState.Idle);
}
})();
return () => {
cancelled = true;
};
}, [sessionId, setMessagesAndLog]);
const handleSubmit = useCallback(
async (userMessage: string) => {
const newMessage: Message = {
role: 'user',
content: [{ type: 'text', text: userMessage }],
created: Date.now(),
metadata: { userVisible: true, agentVisible: true },
};
log.messages('user-submit', messagesRef.current.length + 1, {
userMessageLength: userMessage.length,
});
let currentMessages = [...messages, newMessage];
setMessages(currentMessages);
const currentMessages = [...messagesRef.current, createUserMessage(userMessage)];
setMessagesAndLog(currentMessages, 'user-entered');
log.state(ChatState.Streaming, { reason: 'user submit' });
setChatState(ChatState.Streaming);
abortControllerRef.current = new AbortController();
try {
// TODO(Douwe): this side steps our API. heyapi does support streaming though which should make
// this all nice & typed
log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
const response = await fetch(getApiUrl('/reply'), {
method: 'POST',
headers: {
@ -74,66 +332,57 @@ export function useChatStream({
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error('No response body');
log.stream('response-received', {
status: response.status,
ok: response.ok,
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
await streamFromResponse(
response,
currentMessages,
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
onFinish
);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') continue;
try {
const event = JSON.parse(data);
if (event.message) {
const msg = event.message as Message;
currentMessages = pushMessage(currentMessages, msg);
setMessages(currentMessages);
}
if (event.error) {
console.error('Stream error:', event.error);
setChatState(ChatState.Idle);
return;
}
if (event.finish) {
setChatState(ChatState.Idle);
onStreamFinish?.();
return;
}
} catch (e) {
console.error('Failed to parse SSE:', e);
}
}
}
log.stream('stream-complete');
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
console.error('Stream error:', error);
// AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') {
log.stream('stream-aborted');
} 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)));
}
setChatState(ChatState.Idle);
}
},
[sessionId, messages, setMessages, onStreamFinish]
[sessionId, setMessagesAndLog, onFinish]
);
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 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);
return {
sessionLoadError,
messages: maybe_cached_messages,
session: maybe_cached_session,
chatState,
handleSubmit,
stopStreaming,

View file

@ -12,11 +12,6 @@ export type Recipe = import('../api').Recipe & {
// Properties added for scheduled execution
scheduledJobId?: string;
isScheduledExecution?: boolean;
// TODO: Separate these from the raw recipe type
// Legacy frontend properties (not in OpenAPI schema)
profile?: string;
goosehints?: string;
mcps?: number;
};
export async function encodeRecipe(recipe: Recipe): Promise<string> {

View file

@ -5,7 +5,7 @@ export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' }
export function createUserMessage(text: string): Message {
return {
id: generateId(),
id: generateMessageId(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [{ type: 'text', text }],
@ -15,7 +15,7 @@ export function createUserMessage(text: string): Message {
export function createToolErrorResponseMessage(id: string, error: string): Message {
return {
id: generateId(),
id: generateMessageId(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [
@ -32,7 +32,7 @@ export function createToolErrorResponseMessage(id: string, error: string): Messa
};
}
function generateId(): string {
export function generateMessageId(): string {
return Math.random().toString(36).substring(2, 10);
}