Add session to agents (#4216)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Jack Amadeo <jackamadeo@squareup.com>
Co-authored-by: Jack Amadeo <jackamadeo@block.xyz>
This commit is contained in:
Douwe Osinga
2025-09-03 10:31:24 -04:00
committed by GitHub
parent db94c5b7c3
commit d2195289cf
65 changed files with 1473 additions and 1601 deletions
+30 -23
View File
@@ -41,7 +41,7 @@
* while remaining flexible enough to support different UI contexts (Hub vs Pair).
*/
import React, { useEffect, useContext, createContext, useRef } from 'react';
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import { AgentHeader } from './AgentHeader';
@@ -63,31 +63,31 @@ import { useFileDrop } from '../hooks/useFileDrop';
import { useCostTracking } from '../hooks/useCostTracking';
import { Message } from '../types/message';
import { ChatState } from '../types/chatState';
import { ChatType } from '../types/chat';
import { useToolCount } from './alerts/useToolCount';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
import { ChatType } from '../types/chat';
interface BaseChatProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
enableLocalStorage?: boolean;
onMessageStreamFinish?: () => void;
onMessageSubmit?: (message: string) => void; // Callback after message is submitted
onMessageSubmit?: (message: string) => void;
renderHeader?: () => React.ReactNode;
renderBeforeMessages?: () => React.ReactNode;
renderAfterMessages?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string; // Add custom class for content area
disableSearch?: boolean; // Disable search functionality (for Hub)
showPopularTopics?: boolean; // Show popular chat topics in empty state (for Pair)
suppressEmptyState?: boolean; // Suppress empty state content (for transitions)
contentClassName?: string;
disableSearch?: boolean;
showPopularTopics?: boolean;
suppressEmptyState?: boolean;
autoSubmit?: boolean;
loadingChat: boolean;
}
function BaseChatContent({
@@ -95,7 +95,6 @@ function BaseChatContent({
setChat,
setView,
setIsGoosehintsModalOpen,
enableLocalStorage = false,
onMessageStreamFinish,
onMessageSubmit,
renderHeader,
@@ -108,6 +107,7 @@ function BaseChatContent({
showPopularTopics = false,
suppressEmptyState = false,
autoSubmit = false,
loadingChat = false,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
@@ -131,7 +131,6 @@ function BaseChatContent({
error,
setMessages,
input,
setInput: _setInput,
handleSubmit: engineHandleSubmit,
onStopGoose,
sessionTokenCount,
@@ -158,7 +157,6 @@ function BaseChatContent({
setHasStartedUsingRecipe(true);
}
},
enableLocalStorage,
});
// Use shared recipe manager
@@ -177,7 +175,7 @@ function BaseChatContent({
handleRecipeAccept,
handleRecipeCancel,
hasSecurityWarnings,
} = useRecipeManager(messages, location.state);
} = useRecipeManager(chat, location.state?.recipeConfig);
// Reset recipe usage tracking when recipe changes
useEffect(() => {
@@ -251,6 +249,8 @@ function BaseChatContent({
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
@@ -324,13 +324,11 @@ function BaseChatContent({
{/* Messages or RecipeActivities or Popular Topics */}
{
// Check if we should show splash instead of messages
(() => {
// Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted
const shouldShowSplash =
recipeConfig && recipeAccepted && !hasStartedUsingRecipe && !suppressEmptyState;
return shouldShowSplash;
})() ? (
// Show splash if we have a recipe and user hasn't started using it yet, and recipe has been accepted
loadingChat ? null : recipeConfig &&
recipeAccepted &&
!hasStartedUsingRecipe &&
!suppressEmptyState ? (
<>
{/* Show RecipeActivities when we have a recipe config and user hasn't started using it */}
{recipeConfig ? (
@@ -416,7 +414,7 @@ function BaseChatContent({
null as Message | null
);
if (lastUserMessage) {
append(lastUserMessage);
await append(lastUserMessage);
}
}}
>
@@ -426,6 +424,7 @@ function BaseChatContent({
</div>
</>
)}
<div className="block h-8" />
</>
) : showPopularTopics ? (
@@ -439,10 +438,16 @@ function BaseChatContent({
</ScrollArea>
{/* Fixed loading indicator at bottom left of chat container */}
{(chatState !== ChatState.Idle || isCompacting) && (
{(chatState !== ChatState.Idle || loadingChat || isCompacting) && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={isCompacting ? 'goose is compacting the conversation...' : undefined}
message={
loadingChat
? 'loading conversation...'
: isCompacting
? 'goose is compacting the conversation...'
: undefined
}
chatState={chatState}
/>
</div>
@@ -453,6 +458,7 @@ function BaseChatContent({
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
sessionId={chat.sessionId}
handleSubmit={handleSubmit}
chatState={chatState}
onStop={onStopGoose}
@@ -472,6 +478,7 @@ function BaseChatContent({
recipeConfig={recipeConfig}
recipeAccepted={recipeAccepted}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
autoSubmit={autoSubmit}
setAncestorMessages={setAncestorMessages}
append={append}
+14 -27
View File
@@ -13,7 +13,6 @@ import { DirSwitcher } from './bottom_menu/DirSwitcher';
import ModelsBottomBar from './settings/models/bottom_bar/ModelsBottomBar';
import { BottomMenuModeSelection } from './bottom_menu/BottomMenuModeSelection';
import { AlertType, useAlerts } from './alerts';
import { useToolCount } from './alerts/useToolCount';
import { useConfig } from './ConfigContext';
import { useModelAndProvider } from './ModelAndProviderContext';
import { useWhisper } from '../hooks/useWhisper';
@@ -58,6 +57,7 @@ interface ModelLimit {
}
interface ChatInputProps {
sessionId: string | null;
handleSubmit: (e: React.FormEvent) => void;
chatState: ChatState;
onStop?: () => void;
@@ -83,6 +83,7 @@ interface ChatInputProps {
recipeConfig?: Recipe | null;
recipeAccepted?: boolean;
initialPrompt?: string;
toolCount: number;
autoSubmit: boolean;
setAncestorMessages?: (messages: Message[]) => void;
append?: (message: Message) => void;
@@ -90,6 +91,7 @@ interface ChatInputProps {
}
export default function ChatInput({
sessionId,
handleSubmit,
chatState = ChatState.Idle,
onStop,
@@ -109,6 +111,7 @@ export default function ChatInput({
recipeConfig,
recipeAccepted,
initialPrompt,
toolCount,
autoSubmit = false,
append,
setAncestorMessages,
@@ -133,7 +136,6 @@ export default function ChatInput({
const dropdownRef: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(
null
) as React.RefObject<HTMLDivElement>;
const toolCount = useToolCount();
const { isCompacting, handleManualCompaction } = useContextManager();
const { getProviders, read } = useConfig();
const { getCurrentModelAndProvider, currentModel, currentProvider } = useModelAndProvider();
@@ -296,33 +298,16 @@ export default function ChatInput({
setHasUserTyped(false);
}, [initialValue]); // Keep only initialValue as a dependency
// Track if we've already set the recipe prompt to avoid re-setting it
const hasSetRecipePromptRef = useRef(false);
// Handle recipe prompt updates
useEffect(() => {
// If recipe is accepted and we have an initial prompt, and no messages yet, and we haven't set it before
if (
recipeAccepted &&
initialPrompt &&
messages.length === 0 &&
!hasSetRecipePromptRef.current
) {
if (recipeAccepted && initialPrompt && messages.length === 0) {
setDisplayValue(initialPrompt);
setValue(initialPrompt);
hasSetRecipePromptRef.current = true;
setTimeout(() => {
textAreaRef.current?.focus();
}, 0);
}
// we don't need hasSetRecipePromptRef in the dependency array because it is a ref that persists across renders
}, [recipeAccepted, initialPrompt, messages.length]);
// Reset the recipe prompt flag when the recipe changes or messages are added
useEffect(() => {
if (messages.length > 0 || !recipeAccepted || !initialPrompt) {
hasSetRecipePromptRef.current = false;
}
}, [recipeAccepted, initialPrompt, messages.length]);
// Draft functionality - load draft if no initial value or recipe
@@ -920,6 +905,14 @@ export default function ChatInput({
return true; // Return true if message was queued
};
const canSubmit =
!isLoading &&
!isCompacting &&
agentIsReady &&
(displayValue.trim() ||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
allDroppedFiles.some((file) => !file.error && !file.isLoading));
const performSubmit = useCallback(
(text?: string) => {
const validPastedImageFilesPaths = pastedImages
@@ -1061,13 +1054,6 @@ export default function ChatInput({
return;
}
const canSubmit =
!isLoading &&
!isCompacting &&
agentIsReady &&
(displayValue.trim() ||
pastedImages.some((img) => img.filePath && !img.error && !img.isLoading) ||
allDroppedFiles.some((file) => !file.error && !file.isLoading));
if (canSubmit) {
performSubmit();
}
@@ -1575,6 +1561,7 @@ export default function ChatInput({
<Tooltip>
<div>
<ModelsBottomBar
sessionId={sessionId}
dropdownRef={dropdownRef}
setView={setView}
alerts={alerts}
@@ -29,6 +29,7 @@ import { cn } from '../utils';
interface GooseMessageProps {
// messages up to this index are presumed to be "history" from a resumed session, this is used to track older tool confirmation requests
// anything before this index should not render any buttons, but anything after should
sessionId: string;
messageHistoryIndex: number;
message: Message;
messages: Message[];
@@ -40,6 +41,7 @@ interface GooseMessageProps {
}
export default function GooseMessage({
sessionId,
messageHistoryIndex,
message,
metadata,
@@ -293,6 +295,7 @@ export default function GooseMessage({
{hasToolConfirmation && (
<ToolCallConfirmation
sessionId={sessionId}
isCancelledMessage={messageIndex == messageHistoryIndex - 1}
isClicked={messageIndex < messageHistoryIndex}
toolConfirmationId={toolConfirmationContent.id}
@@ -1,8 +1,7 @@
import React, { createContext, useContext, useState, useEffect, useMemo, useCallback } from 'react';
import { initializeAgent } from '../agent';
import { toastError, toastSuccess } from '../toasts';
import Model, { getProviderMetadata } from './settings/models/modelInterface';
import { ProviderMetadata } from '../api';
import { ProviderMetadata, updateAgentProvider } from '../api';
import { useConfig } from './ConfigContext';
import {
getModelDisplayName,
@@ -26,7 +25,7 @@ const SWITCH_MODEL_SUCCESS_MSG = 'Successfully switched models';
interface ModelAndProviderContextType {
currentModel: string | null;
currentProvider: string | null;
changeModel: (model: Model) => Promise<void>;
changeModel: (sessionId: string | null, model: Model) => Promise<void>;
getCurrentModelAndProvider: () => Promise<{ model: string; provider: string }>;
getFallbackModelAndProvider: () => Promise<{ model: string; provider: string }>;
getCurrentModelAndProviderForDisplay: () => Promise<{ model: string; provider: string }>;
@@ -44,50 +43,43 @@ const ModelAndProviderContext = createContext<ModelAndProviderContextType | unde
export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> = ({ children }) => {
const [currentModel, setCurrentModel] = useState<string | null>(null);
const [currentProvider, setCurrentProvider] = useState<string | null>(null);
const { read, upsert, getProviders, config } = useConfig();
const { read, upsert, getProviders } = useConfig();
const changeModel = useCallback(
async (model: Model) => {
async (sessionId: string | null, model: Model) => {
const modelName = model.name;
const providerName = model.provider;
try {
await initializeAgent({
model: model.name,
provider: model.provider,
});
} catch (error) {
console.error(`Failed to change model at agent step -- ${modelName} ${providerName}`);
toastError({
title: CHANGE_MODEL_ERROR_TITLE,
msg: SWITCH_MODEL_AGENT_ERROR_MSG,
traceback: error instanceof Error ? error.message : String(error),
});
// don't write to config
return;
}
let phase = 'agent';
try {
if (sessionId) {
await updateAgentProvider({
body: {
session_id: sessionId,
provider: providerName,
model: modelName,
},
});
}
phase = 'config';
await upsert('GOOSE_PROVIDER', providerName, false);
await upsert('GOOSE_MODEL', modelName, false);
// Update local state
setCurrentProvider(providerName);
setCurrentModel(modelName);
} catch (error) {
console.error(`Failed to change model at config step -- ${modelName} ${providerName}}`);
toastError({
title: CHANGE_MODEL_ERROR_TITLE,
msg: CONFIG_UPDATE_ERROR_MSG,
traceback: error instanceof Error ? error.message : String(error),
});
// agent and config will be out of sync at this point
// TODO: reset agent to use current config settings
} finally {
// show toast
toastSuccess({
title: CHANGE_MODEL_TOAST_TITLE,
msg: `${SWITCH_MODEL_SUCCESS_MSG} -- using ${model.alias ?? modelName} from ${model.subtext ?? providerName}`,
});
} catch (error) {
console.error(`Failed to change model at ${phase} step -- ${modelName} ${providerName}`);
toastError({
title: CHANGE_MODEL_ERROR_TITLE,
msg: phase === 'agent' ? SWITCH_MODEL_AGENT_ERROR_MSG : CONFIG_UPDATE_ERROR_MSG,
traceback: error instanceof Error ? error.message : String(error),
});
}
},
[upsert]
@@ -183,19 +175,6 @@ export const ModelAndProviderProvider: React.FC<ModelAndProviderProviderProps> =
refreshCurrentModelAndProvider();
}, [refreshCurrentModelAndProvider]);
// Extract config values for dependency array
const configObj = config as Record<string, unknown>;
const gooseModel = configObj?.GOOSE_MODEL;
const gooseProvider = configObj?.GOOSE_PROVIDER;
// Listen for config changes and refresh when GOOSE_MODEL or GOOSE_PROVIDER changes
useEffect(() => {
// Only refresh if the config has loaded and model/provider values exist
if (config && Object.keys(config).length > 0 && (gooseModel || gooseProvider)) {
refreshCurrentModelAndProvider();
}
}, [config, gooseModel, gooseProvider, refreshCurrentModelAndProvider]);
const contextValue = useMemo(
() => ({
currentModel,
@@ -143,7 +143,8 @@ describe('OllamaSetup', () => {
});
});
describe('when Ollama and model are both available', () => {
// TODO: re-enable when we have ollama back in the onboarding
describe.skip('when Ollama and model are both available', () => {
beforeEach(() => {
vi.mocked(ollamaDetection.checkOllamaStatus).mockResolvedValue({
isRunning: true,
+4 -11
View File
@@ -9,18 +9,18 @@ import {
getPreferredModel,
type PullProgress,
} from '../utils/ollamaDetection';
import { initializeSystem } from '../utils/providerUtils';
//import { initializeSystem } from '../utils/providerUtils';
import { toastService } from '../toasts';
import { Ollama } from './icons';
interface OllamaSetupProps {
onSuccess: () => void;
onCancel: () => void;
setIsExtensionsLoading?: (loading: boolean) => void;
}
export function OllamaSetup({ onSuccess, onCancel, setIsExtensionsLoading }: OllamaSetupProps) {
const { addExtension, getExtensions, upsert } = useConfig();
export function OllamaSetup({ onSuccess, onCancel }: OllamaSetupProps) {
//const { addExtension, getExtensions, upsert } = useConfig();
const { upsert } = useConfig();
const [isChecking, setIsChecking] = useState(true);
const [ollamaDetected, setOllamaDetected] = useState(false);
const [isPolling, setIsPolling] = useState(false);
@@ -110,13 +110,6 @@ export function OllamaSetup({ onSuccess, onCancel, setIsExtensionsLoading }: Oll
await upsert('GOOSE_MODEL', getPreferredModel(), false);
await upsert('OLLAMA_HOST', 'localhost', false);
// Initialize the system with Ollama
await initializeSystem('ollama', getPreferredModel(), {
getExtensions,
addExtension,
setIsExtensionsLoading,
});
toastService.success({
title: 'Success!',
msg: `Connected to Ollama with ${getPreferredModel()} model.`,
@@ -14,7 +14,7 @@
* - Configurable batch size and delay
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { Message } from '../types/message';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
@@ -22,10 +22,11 @@ import { CompactionMarker } from './context_management/CompactionMarker';
import { useContextManager } from './context_management/ContextManager';
import { NotificationEvent } from '../hooks/useMessageStream';
import LoadingGoose from './LoadingGoose';
import { ChatType } from '../types/chat';
interface ProgressiveMessageListProps {
messages: Message[];
chat?: { id: string; messageHistoryIndex: number }; // Make optional for session history
chat?: Pick<ChatType, 'sessionId' | 'messageHistoryIndex'>;
toolCallNotifications?: Map<string, NotificationEvent[]>; // Make optional
append?: (value: string) => void; // Make optional
appendMessage?: (message: Message) => void; // Make optional
@@ -152,8 +153,7 @@ export default function ProgressiveMessageList({
// Render messages up to the current rendered count
const renderMessages = useCallback(() => {
const messagesToRender = messages.slice(0, renderedCount);
const renderedMessages = messagesToRender
return messagesToRender
.map((message, index) => {
// Use custom render function if provided
if (renderMessage) {
@@ -170,7 +170,7 @@ export default function ProgressiveMessageList({
const isUser = isUserMessage(message);
const result = (
return (
<div
key={message.id && `${message.id}-${message.content.length}`}
className={`relative ${index === 0 ? 'mt-0' : 'mt-4'} ${isUser ? 'user' : 'assistant'}`}
@@ -192,6 +192,7 @@ export default function ProgressiveMessageList({
<CompactionMarker message={message} />
) : (
<GooseMessage
sessionId={chat.sessionId}
messageHistoryIndex={chat.messageHistoryIndex}
message={message}
messages={messages}
@@ -210,12 +211,8 @@ export default function ProgressiveMessageList({
)}
</div>
);
return result;
})
.filter(Boolean); // Filter out null values
return renderedMessages;
.filter(Boolean);
}, [
messages,
renderedCount,
+8 -21
View File
@@ -5,7 +5,6 @@ import { SetupModal } from './SetupModal';
import { startOpenRouterSetup } from '../utils/openRouterSetup';
import { startTetrateSetup } from '../utils/tetrateSetup';
import WelcomeGooseLogo from './WelcomeGooseLogo';
import { initializeSystem } from '../utils/providerUtils';
import { toastService } from '../toasts';
import { OllamaSetup } from './OllamaSetup';
@@ -13,12 +12,12 @@ import { Goose } from './icons/Goose';
import { OpenRouter } from './icons';
interface ProviderGuardProps {
didSelectProvider: boolean;
children: React.ReactNode;
setIsExtensionsLoading?: (loading: boolean) => void;
}
export default function ProviderGuard({ children, setIsExtensionsLoading }: ProviderGuardProps) {
const { read, getExtensions, addExtension } = useConfig();
export default function ProviderGuard({ didSelectProvider, children }: ProviderGuardProps) {
const { read } = useConfig();
const navigate = useNavigate();
const [isChecking, setIsChecking] = useState(true);
const [hasProvider, setHasProvider] = useState(false);
@@ -69,13 +68,6 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
// Initialize the system with the new provider/model
await initializeSystem(provider as string, model as string, {
getExtensions,
addExtension,
setIsExtensionsLoading,
});
toastService.configure({ silent: false });
toastService.success({
title: 'Success!',
@@ -136,13 +128,6 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov
const model = (await read('GOOSE_MODEL', false)) ?? config.GOOSE_DEFAULT_MODEL;
if (provider && model) {
// Initialize the system with the new provider/model
await initializeSystem(provider as string, model as string, {
getExtensions,
addExtension,
setIsExtensionsLoading,
});
toastService.configure({ silent: false });
toastService.success({
title: 'Success!',
@@ -207,8 +192,11 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov
};
checkProvider();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [read]);
}, [
navigate,
read,
didSelectProvider, // When the user makes a selection, re-trigger this check
]);
if (
isChecking &&
@@ -270,7 +258,6 @@ export default function ProviderGuard({ children, setIsExtensionsLoading }: Prov
setShowOllamaSetup(false);
setShowFirstTimeSetup(true);
}}
setIsExtensionsLoading={setIsExtensionsLoading}
/>
</div>
</div>
+1 -6
View File
@@ -16,12 +16,7 @@ import { toastSuccess, toastError } from '../toasts';
import { useEscapeKey } from '../hooks/useEscapeKey';
import { deleteRecipe, RecipeManifestResponse } from '../api';
interface RecipesViewProps {
onLoadRecipe?: (recipe: Recipe) => void;
}
// @ts-expect-error until we make onLoadRecipe work for loading recipes in the same window
export default function RecipesView({ _onLoadRecipe }: RecipesViewProps = {}) {
export default function RecipesView() {
const [savedRecipes, setSavedRecipes] = useState<RecipeManifestResponse[]>([]);
const [loading, setLoading] = useState(true);
const [showSkeleton, setShowSkeleton] = useState(true);
@@ -21,6 +21,7 @@ const toolConfirmationState = new Map<
>();
interface ToolConfirmationProps {
sessionId: string;
isCancelledMessage: boolean;
isClicked: boolean;
toolConfirmationId: string;
@@ -28,6 +29,7 @@ interface ToolConfirmationProps {
}
export default function ToolConfirmation({
sessionId,
isCancelledMessage,
isClicked,
toolConfirmationId,
@@ -68,34 +70,37 @@ export default function ToolConfirmation({
}
}, [isClicked, clicked, status, toolName, toolConfirmationId]);
const handleButtonClick = async (action: string) => {
const newClicked = true;
const newStatus = action;
let newActionDisplay = '';
const handleButtonClick = async (newStatus: string) => {
let newActionDisplay;
if (action === ALWAYS_ALLOW) {
if (newStatus === ALWAYS_ALLOW) {
newActionDisplay = 'always allowed';
} else if (action === ALLOW_ONCE) {
} else if (newStatus === ALLOW_ONCE) {
newActionDisplay = 'allowed once';
} else {
newActionDisplay = 'denied';
}
// Update local state
setClicked(newClicked);
setClicked(true);
setStatus(newStatus);
setActionDisplay(newActionDisplay);
// Store in global state for persistence across navigation
toolConfirmationState.set(toolConfirmationId, {
clicked: newClicked,
clicked: true,
status: newStatus,
actionDisplay: newActionDisplay,
});
try {
const response = await confirmPermission({
body: { id: toolConfirmationId, action, principal_type: 'Tool' },
body: {
session_id: sessionId,
id: toolConfirmationId,
action: newStatus,
principal_type: 'Tool',
},
});
if (response.error) {
console.error('Failed to confirm permission:', response.error);
@@ -3,7 +3,7 @@ import { getTools } from '../../api';
const { clearTimeout } = window;
export const useToolCount = () => {
export const useToolCount = (sessionId: string) => {
const [toolCount, setToolCount] = useState<number | null>(null);
useEffect(() => {
@@ -11,7 +11,7 @@ export const useToolCount = () => {
const fetchTools = async () => {
try {
const response = await getTools();
const response = await getTools({ query: { session_id: sessionId } });
if (!response.error && response.data) {
setToolCount(response.data.length);
} else {
@@ -30,7 +30,7 @@ export const useToolCount = () => {
return () => {
clearTimeout(timeoutId);
};
}, []);
}, [sessionId]);
return toolCount;
};
@@ -50,6 +50,7 @@ export async function manageContextFromBackend({
}
// Function to convert API Message to frontend Message
// TODO(Douwe): get rid of this and use the API Message format everywhere
export function convertApiMessageToFrontendMessage(
apiMessage: ApiMessage,
display?: boolean,
+8 -34
View File
@@ -7,44 +7,30 @@
* Key Responsibilities:
* - Displays SessionInsights to show session statistics and recent chats
* - Provides a ChatInput for users to start new conversations
* - Creates a new chat session with the submitted message and navigates to Pair
* - Navigates to Pair with the submitted message to start a new conversation
* - Ensures each submission from Hub always starts a fresh conversation
*
* Navigation Flow:
* Hub (input submission) → Pair (new conversation with the submitted message)
*
* Unlike the previous implementation that used BaseChat, the Hub now uses only
* ChatInput directly, which allows for clean separation between the landing page
* and active conversation states. This ensures that every message submitted from
* the Hub creates a brand new chat session in the Pair view.
*/
import { SessionInsights } from './sessions/SessionsInsights';
import ChatInput from './ChatInput';
import { generateSessionId } from '../sessions';
import { ChatState } from '../types/chatState';
import { ContextManagerProvider } from './context_management/ContextManager';
import 'react-toastify/dist/ReactToastify.css';
import { ChatType } from '../types/chat';
import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext';
import { View, ViewOptions } from '../utils/navigationUtils';
export default function Hub({
chat: _chat,
setChat: _setChat,
setPairChat,
setView,
setIsGoosehintsModalOpen,
isExtensionsLoading,
resetChat,
}: {
readyForAutoUserPrompt: boolean;
chat: ChatType;
setChat: (chat: ChatType) => void;
setPairChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
isExtensionsLoading: boolean;
resetChat: () => void;
}) {
// Handle chat input submission - create new chat and navigate to pair
const handleSubmit = (e: React.FormEvent) => {
@@ -52,29 +38,15 @@ export default function Hub({
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
// Always create a completely new chat session with a unique ID for the PAIR
const newChatId = generateSessionId();
const newPairChat = {
id: newChatId, // This generates a unique ID each time
title: DEFAULT_CHAT_TITLE,
messages: [], // Always start with empty messages
messageHistoryIndex: 0,
recipeConfig: null, // Clear recipe for new chats from Hub
recipeParameters: null, // Clear parameters for new chats from Hub
};
// Update the PAIR chat state immediately to prevent flashing
setPairChat(newPairChat);
// Navigate to pair page with the message to be submitted immediately
// 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,
resetChat: true,
});
}
// Prevent default form submission
e.preventDefault();
};
@@ -86,6 +58,7 @@ export default function Hub({
</div>
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
@@ -104,6 +77,7 @@ export default function Hub({
sessionCosts={undefined}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
toolCount={0}
/>
</div>
</ContextManagerProvider>
+72 -107
View File
@@ -1,145 +1,111 @@
/**
* Pair Component
*
* The Pair component represents the active conversation mode in the Goose Desktop application.
* This is where users engage in ongoing conversations with the AI assistant after transitioning
* from the Hub's initial welcome screen.
*
* Key Responsibilities:
* - Manages active chat sessions with full message history
* - Handles transitions from Hub with initial input processing
* - Provides the main conversational interface for extended interactions
* - Enables local storage persistence for conversation continuity
* - Supports all advanced chat features like file attachments, tool usage, etc.
*
* Navigation Flow:
* Hub (initial message) → Pair (active conversation) → Hub (new session)
*
* The Pair component is essentially a specialized wrapper around BaseChat that:
* - Processes initial input from the Hub transition
* - Enables conversation persistence
* - Provides the full-featured chat experience
*
* Unlike Hub, Pair assumes an active conversation state and focuses on
* maintaining conversation flow rather than onboarding new users.
*/
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { View, ViewOptions } from '../utils/navigationUtils';
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 { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext';
export interface PairRouteState {
resumeSessionId?: string;
initialMessage?: string;
}
interface PairProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
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<ChatType>;
}
export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
}) {
const location = useLocation();
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 [initialMessage, setInitialMessage] = useState<string | null>(null);
const [messageToSubmit, setMessageToSubmit] = useState<string | null>(null);
const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false);
const [loadingChat, setLoadingChat] = useState(false);
// Get recipe configuration and parameter handling
const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat.messages, location.state);
// Handle recipe loading from recipes view - reset chat if needed
useEffect(() => {
if (location.state?.resetChat && location.state?.recipeConfig) {
// Reset the chat to start fresh with the recipe
const newChat = {
id: chat.id, // Keep the same ID to maintain the session
title: location.state.recipeConfig.title || 'Recipe Chat',
messages: [], // Clear messages to start fresh
messageHistoryIndex: 0,
recipeConfig: location.state.recipeConfig, // Set the recipe config in chat state
recipeParameters: null, // Clear parameters for new recipe
};
setChat(newChat);
const initializeFromState = async () => {
setLoadingChat(true);
try {
const chat = await loadCurrentChat({
resumeSessionId: resumeSessionId,
setAgentWaitingMessage,
});
setChat(chat);
} catch (error) {
console.log(error);
setFatalError(`Agent init failure: ${error instanceof Error ? error.message : '' + error}`);
} finally {
setLoadingChat(false);
}
};
initializeFromState();
}, [
agentState,
setChat,
setFatalError,
setAgentWaitingMessage,
loadCurrentChat,
resumeSessionId,
]);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state, chat.id]);
// Handle initial message from hub page
// Followed by sending the initialMessage if we have one. This will happen
// only once, unless we reset the chat in step one.
useEffect(() => {
const messageFromHub = location.state?.initialMessage;
const resetChat = location.state?.resetChat;
// If we have a resetChat flag from Hub, clear any existing recipe config
// This scenario occurs when a user navigates from Hub to start a new chat,
// ensuring any previous recipe configuration is cleared for a fresh start
if (resetChat) {
const newChat: ChatType = {
...chat,
recipeConfig: null,
recipeParameters: null,
title: DEFAULT_CHAT_TITLE,
messages: [], // Clear messages for fresh start
messageHistoryIndex: 0,
};
setChat(newChat);
if (agentState !== AgentState.INITIALIZED || !initialMessage || hasProcessedInitialInput) {
return;
}
// Reset processing state when we have a new message from hub
if (messageFromHub) {
// Set transitioning state to prevent showing popular topics
setIsTransitioningFromHub(true);
setIsTransitioningFromHub(true);
setHasProcessedInitialInput(true);
setMessageToSubmit(initialMessage);
setShouldAutoSubmit(true);
}, [agentState, initialMessage, hasProcessedInitialInput]);
// If this is a different message than what we processed before, reset the flag
if (messageFromHub !== initialMessage) {
setHasProcessedInitialInput(false);
}
if (!hasProcessedInitialInput) {
setHasProcessedInitialInput(true);
setInitialMessage(messageFromHub);
setShouldAutoSubmit(true);
// Clear the location state to prevent re-processing
window.history.replaceState({}, '', '/pair');
}
useEffect(() => {
if (agentState === AgentState.NO_PROVIDER) {
setView('welcome');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.state, hasProcessedInitialInput, initialMessage]);
}, [agentState, setView]);
const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat, chat.recipeConfig || null);
// Custom message submit handler
const handleMessageSubmit = (message: string) => {
// This is called after a message is submitted
// Clean up any auto submit state:
setShouldAutoSubmit(false);
setIsTransitioningFromHub(false); // Clear transitioning state once message is submitted
setIsTransitioningFromHub(false);
setMessageToSubmit(null);
console.log('Message submitted:', message);
};
// Custom message stream finish handler to handle recipe auto-execution
const handleMessageStreamFinish = () => {
// This will be called with the proper append function from BaseChat
// For now, we'll handle auto-execution in the BaseChat component
};
const recipePrompt =
agentState === 'initialized' && chat.messages.length === 0 && recipeInitialPrompt;
// Determine the initial value for the chat input
// Priority: Hub message > Recipe prompt > empty
const initialValue = initialMessage || recipeInitialPrompt || undefined;
const initialValue = messageToSubmit || recipePrompt || undefined;
// Custom chat input props for Pair-specific behavior
const customChatInputProps = {
// Pass initial message from Hub or recipe prompt
initialValue,
@@ -148,13 +114,12 @@ export default function Pair({
return (
<BaseChat
chat={chat}
loadingChat={loadingChat}
autoSubmit={shouldAutoSubmit}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
enableLocalStorage={true} // Enable local storage for Pair mode
onMessageSubmit={handleMessageSubmit}
onMessageStreamFinish={handleMessageStreamFinish}
customChatInputProps={customChatInputProps}
contentClassName={cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11')} // Use dynamic content class with mobile margin and sidebar state
showPopularTopics={!isTransitioningFromHub} // Don't show popular topics while transitioning from Hub
@@ -459,7 +459,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
setSelectedSessionDetails(null);
setSessionDetailsError(null);
}}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails.session_id)}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails?.sessionId)}
showActionButtons={true}
/>
);
@@ -11,7 +11,7 @@ import {
LoaderCircle,
AlertCircle,
} from 'lucide-react';
import { type SessionDetails } from '../../sessions';
import { resumeSession, type SessionDetails } from '../../sessions';
import { Button } from '../ui/button';
import { toast } from 'react-toastify';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
@@ -38,10 +38,7 @@ const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
if (message.content.every((c) => c.type === 'toolConfirmationRequest')) {
return false;
}
return true;
return !message.content.every((c) => c.type === 'toolConfirmationRequest');
};
const filterMessagesForDisplay = (messages: Message[]): Message[] => {
@@ -112,7 +109,7 @@ const SessionMessages: React.FC<{
<ProgressiveMessageList
messages={filteredMessages}
chat={{
id: 'session-preview',
sessionId: 'session-preview',
messageHistoryIndex: filteredMessages.length,
}}
toolCallNotifications={new Map()}
@@ -189,7 +186,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session.metadata.working_dir,
session.messages,
session.metadata.description || 'Shared Session',
session.metadata.total_tokens
session.metadata.total_tokens || 0
);
const shareableLink = `goose://sessions/${shareToken}`;
@@ -219,31 +216,10 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
};
const handleLaunchInNewWindow = () => {
if (session) {
console.log('Launching session in new window:', session.session_id);
console.log('Session details:', session);
// Get the working directory from the session metadata
const workingDir = session.metadata?.working_dir;
if (workingDir) {
console.log(
`Opening new window with session ID: ${session.session_id}, in working dir: ${workingDir}`
);
// Create a new chat window with the working directory and session ID
window.electron.createChatWindow(
undefined, // query
workingDir, // dir
undefined, // version
session.session_id // resumeSessionId
);
console.log('createChatWindow called successfully');
} else {
console.error('No working directory found in session metadata');
toast.error('Could not launch session: Missing working directory');
}
try {
resumeSession(session);
} catch (error) {
toast.error(`Could not launch session: ${error instanceof Error ? error.message : error}`);
}
};
@@ -312,7 +288,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
{session.metadata.total_tokens !== null && (
<span className="flex items-center">
<Target className="w-4 h-4 mr-1" />
{session.metadata.total_tokens.toLocaleString()}
{(session.metadata.total_tokens || 0).toLocaleString()}
</span>
)}
</div>
@@ -413,7 +413,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span className="font-mono">
{session.metadata.total_tokens.toLocaleString()}
{(session.metadata.total_tokens || 0).toLocaleString()}
</span>
</div>
)}
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription } from '../ui/card';
import { getApiUrl } from '../../config';
import { Greeting } from '../common/Greeting';
import { fetchSessions, fetchSessionDetails, type Session } from '../../sessions';
import { fetchSessions, type Session, resumeSession } from '../../sessions';
import { useNavigate } from 'react-router-dom';
import { Button } from '../ui/button';
import { ChatSmart } from '../icons/';
@@ -104,21 +104,13 @@ export function SessionInsights() {
};
}, []);
const handleSessionClick = async (sessionId: string) => {
const handleSessionClick = async (session: Session) => {
try {
// Fetch the session details
const sessionDetails = await fetchSessionDetails(sessionId);
// Navigate to pair view with the resumed session
navigate('/pair', {
state: { resumedSession: sessionDetails },
replace: true,
});
resumeSession(session);
} catch (error) {
console.error('Failed to load session:', error);
// Fallback to the sessions view if loading fails
console.error('Failed to start session:', error);
navigate('/sessions', {
state: { selectedSessionId: sessionId },
state: { selectedSessionId: session.id },
replace: true,
});
}
@@ -358,13 +350,13 @@ export function SessionInsights() {
<div
key={session.id}
className="flex items-center justify-between text-sm py-1 px-2 rounded-md hover:bg-background-muted cursor-pointer transition-colors session-item"
onClick={() => handleSessionClick(session.id)}
onClick={() => handleSessionClick(session)}
role="button"
tabIndex={0}
style={{ animationDelay: `${index * 0.1}s` }}
onKeyDown={async (e) => {
if (e.key === 'Enter' || e.key === ' ') {
await handleSessionClick(session.id);
await handleSessionClick(session);
}
}}
>
@@ -68,7 +68,7 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const handleRetryLoadSession = () => {
if (selectedSession) {
loadSessionDetails(selectedSession.session_id);
loadSessionDetails(selectedSession.sessionId);
}
};
@@ -78,7 +78,7 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
<SessionHistoryView
session={
selectedSession || {
session_id: initialSessionId || '',
sessionId: initialSessionId || '',
messages: [],
metadata: {
description: 'Loading...',
@@ -97,7 +97,7 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
<SessionListView
setView={setView}
onSelectSession={handleSelectSession}
selectedSessionId={selectedSession?.session_id ?? null}
selectedSessionId={selectedSession?.sessionId ?? null}
/>
);
};
@@ -1,7 +1,7 @@
import { Sliders, ChefHat, Bot, Eye, Save } from 'lucide-react';
import React, { useEffect, useState } from 'react';
import { useModelAndProvider } from '../../../ModelAndProviderContext';
import { AddModelModal } from '../subcomponents/AddModelModal';
import { SwitchModelModal } from '../subcomponents/SwitchModelModal';
import { LeadWorkerSettings } from '../subcomponents/LeadWorkerSettings';
import { View } from '../../../../utils/navigationUtils';
import {
@@ -22,6 +22,7 @@ import { toastSuccess, toastError } from '../../../../toasts';
import ViewRecipeModal from '../../../ViewRecipeModal';
interface ModelsBottomBarProps {
sessionId: string | null;
dropdownRef: React.RefObject<HTMLDivElement>;
setView: (view: View) => void;
alerts: Alert[];
@@ -30,6 +31,7 @@ interface ModelsBottomBarProps {
}
export default function ModelsBottomBar({
sessionId,
dropdownRef,
setView,
alerts,
@@ -284,7 +286,11 @@ export default function ModelsBottomBar({
</DropdownMenu>
{isAddModelModalOpen ? (
<AddModelModal setView={setView} onClose={() => setIsAddModelModalOpen(false)} />
<SwitchModelModal
sessionId={sessionId}
setView={setView}
onClose={() => setIsAddModelModalOpen(false)}
/>
) : null}
{isLeadWorkerModalOpen ? (
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { Button } from '../../../ui/button';
import { AddModelModal } from './AddModelModal';
import { SwitchModelModal } from './SwitchModelModal';
import type { View } from '../../../../utils/navigationUtils';
import { shouldShowPredefinedModels } from '../predefinedModelsUtils';
@@ -23,7 +23,11 @@ export default function ModelSettingsButtons({ setView }: ConfigureModelButtonsP
Switch models
</Button>
{isAddModelModalOpen ? (
<AddModelModal setView={setView} onClose={() => setIsAddModelModalOpen(false)} />
<SwitchModelModal
sessionId={null}
setView={setView}
onClose={() => setIsAddModelModalOpen(false)}
/>
) : null}
{!hasPredefinedModels && (
<Button
@@ -19,11 +19,12 @@ import type { View } from '../../../../utils/navigationUtils';
import Model, { getProviderMetadata } from '../modelInterface';
import { getPredefinedModelsFromEnv, shouldShowPredefinedModels } from '../predefinedModelsUtils';
type AddModelModalProps = {
type SwitchModelModalProps = {
sessionId: string | null;
onClose: () => void;
setView: (view: View) => void;
};
export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
export const SwitchModelModal = ({ sessionId, onClose, setView }: SwitchModelModalProps) => {
const { getProviders, getProviderModels, read } = useConfig();
const { changeModel } = useModelAndProvider();
const [providerOptions, setProviderOptions] = useState<{ value: string; label: string }[]>([]);
@@ -92,7 +93,7 @@ export const AddModelModal = ({ onClose, setView }: AddModelModalProps) => {
modelObj = { name: model, provider: provider, subtext: providerDisplayName } as Model;
}
await changeModel(modelObj);
await changeModel(sessionId, modelObj);
onClose();
}
};
@@ -40,7 +40,10 @@ export default function PermissionModal({ extensionName, onClose }: PermissionMo
useEffect(() => {
const fetchTools = async () => {
try {
const response = await getTools({ query: { extension_name: extensionName } });
const response = await getTools({
// TODO(Douwe): pass session ID or maybe? do we configure the tools for the agent or globally?
query: { extension_name: extensionName, session_id: '' },
});
if (response.error) {
console.error('Failed to get tools');
} else {
@@ -3,22 +3,16 @@ import { ScrollArea } from '../../ui/scroll-area';
import BackButton from '../../ui/BackButton';
import ProviderGrid from './ProviderGrid';
import { useConfig } from '../../ConfigContext';
import { ProviderDetails } from '../../../api/types.gen';
import { initializeSystem } from '../../../utils/providerUtils';
import { ProviderDetails } from '../../../api';
import { toastService } from '../../../toasts';
interface ProviderSettingsProps {
onClose: () => void;
isOnboarding: boolean;
setIsExtensionsLoading?: (loading: boolean) => void;
}
export default function ProviderSettings({
onClose,
isOnboarding,
setIsExtensionsLoading,
}: ProviderSettingsProps) {
const { getProviders, upsert, getExtensions, addExtension } = useConfig();
export default function ProviderSettings({ onClose, isOnboarding }: ProviderSettingsProps) {
const { getProviders, upsert } = useConfig();
const [loading, setLoading] = useState(true);
const [providers, setProviders] = useState<ProviderDetails[]>([]);
const initialLoadDone = useRef(false);
@@ -72,13 +66,6 @@ export default function ProviderSettings({
console.log('Setting GOOSE_MODEL to', model)
);
// initialize agent
await initializeSystem(provider.name, model, {
getExtensions,
addExtension,
setIsExtensionsLoading,
});
toastService.configure({ silent: false });
toastService.success({
title: 'Success!',
@@ -98,7 +85,7 @@ export default function ProviderSettings({
});
}
},
[onClose, upsert, getExtensions, addExtension, setIsExtensionsLoading]
[onClose, upsert]
);
return (