Next camp refactor live (#5706)

This commit is contained in:
Zane
2025-11-21 14:10:34 -07:00
committed by GitHub
parent c1c772b267
commit d54a06afda
35 changed files with 1105 additions and 2601 deletions
+4
View File
@@ -368,6 +368,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::export_session,
super::routes::session::import_session,
super::routes::session::update_session_user_recipe_values,
super::routes::session::edit_message,
super::routes::schedule::create_schedule,
super::routes::schedule::list_schedules,
super::routes::schedule::delete_schedule,
@@ -414,6 +415,9 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::UpdateSessionNameRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest,
super::routes::session::UpdateSessionUserRecipeValuesResponse,
super::routes::session::EditType,
super::routes::session::EditMessageRequest,
super::routes::session::EditMessageResponse,
Message,
MessageContent,
MessageMetadata,
+84
View File
@@ -49,6 +49,31 @@ pub struct ImportSessionRequest {
json: String,
}
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum EditType {
Fork,
Edit,
}
#[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EditMessageRequest {
timestamp: i64,
#[serde(default = "default_edit_type")]
edit_type: EditType,
}
fn default_edit_type() -> EditType {
EditType::Fork
}
#[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")]
pub struct EditMessageResponse {
session_id: String,
}
const MAX_NAME_LENGTH: usize = 200;
#[utoipa::path(
@@ -307,6 +332,64 @@ async fn import_session(
Ok(Json(session))
}
#[utoipa::path(
post,
path = "/sessions/{session_id}/edit_message",
request_body = EditMessageRequest,
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session prepared for editing - frontend should submit the edited message", body = EditMessageResponse),
(status = 400, description = "Bad request - Invalid message timestamp"),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session or message not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn edit_message(
Path(session_id): Path<String>,
Json(request): Json<EditMessageRequest>,
) -> Result<Json<EditMessageResponse>, StatusCode> {
match request.edit_type {
EditType::Fork => {
let new_session = SessionManager::copy_session(&session_id, "(edited)".to_string())
.await
.map_err(|e| {
tracing::error!("Failed to copy session: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
SessionManager::truncate_conversation(&new_session.id, request.timestamp)
.await
.map_err(|e| {
tracing::error!("Failed to truncate conversation: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(EditMessageResponse {
session_id: new_session.id,
}))
}
EditType::Edit => {
SessionManager::truncate_conversation(&session_id, request.timestamp)
.await
.map_err(|e| {
tracing::error!("Failed to truncate conversation: {}", e);
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(EditMessageResponse {
session_id: session_id.clone(),
}))
}
}
}
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
@@ -320,5 +403,6 @@ pub fn routes(state: Arc<AppState>) -> Router {
"/sessions/{session_id}/user_recipe_values",
put(update_session_user_recipe_values),
)
.route("/sessions/{session_id}/edit_message", post(edit_message))
.with_state(state)
}
-2
View File
@@ -805,9 +805,7 @@ impl Agent {
} else {
SessionManager::add_message(&session_config.id, &user_message).await?;
}
let session = SessionManager::get_session(&session_config.id, true).await?;
let conversation = session
.conversation
.clone()
+8 -6
View File
@@ -71,12 +71,14 @@ impl Agent {
}
});
let confirmation = Message::user().with_tool_confirmation_request(
request.id.clone(),
tool_call.name.to_string().clone(),
tool_call.arguments.clone().unwrap_or_default(),
security_message,
);
let confirmation = Message::assistant()
.with_tool_confirmation_request(
request.id.clone(),
tool_call.name.to_string().clone(),
tool_call.arguments.clone().unwrap_or_default(),
security_message,
)
.user_only();
yield confirmation;
let mut rx = self.confirmation_rx.lock().await;
@@ -307,6 +307,20 @@ impl SessionManager {
Self::instance().await?.import_session(json).await
}
pub async fn copy_session(session_id: &str, new_name: String) -> Result<Session> {
Self::instance()
.await?
.copy_session(session_id, new_name)
.await
}
pub async fn truncate_conversation(session_id: &str, timestamp: i64) -> Result<()> {
Self::instance()
.await?
.truncate_conversation(session_id, timestamp)
.await
}
pub async fn maybe_update_name(id: &str, provider: Arc<dyn Provider>) -> Result<()> {
let session = Self::get_session(id, true).await?;
@@ -1214,6 +1228,43 @@ impl SessionStorage {
self.get_session(&session.id, true).await
}
async fn copy_session(&self, session_id: &str, new_name: String) -> Result<Session> {
let original_session = self.get_session(session_id, true).await?;
let new_session = self
.create_session(
original_session.working_dir.clone(),
new_name,
original_session.session_type,
)
.await?;
let builder = SessionUpdateBuilder::new(new_session.id.clone())
.extension_data(original_session.extension_data)
.schedule_id(original_session.schedule_id)
.recipe(original_session.recipe)
.user_recipe_values(original_session.user_recipe_values);
self.apply_update(builder).await?;
if let Some(conversation) = original_session.conversation {
self.replace_conversation(&new_session.id, &conversation)
.await?;
}
self.get_session(&new_session.id, true).await
}
async fn truncate_conversation(&self, session_id: &str, timestamp: i64) -> Result<()> {
sqlx::query("DELETE FROM messages WHERE session_id = ? AND created_timestamp >= ?")
.bind(session_id)
.bind(timestamp)
.execute(&self.pool)
.await?;
Ok(())
}
async fn search_chat_history(
&self,
query: &str,
+91
View File
@@ -2021,6 +2021,64 @@
]
}
},
"/sessions/{session_id}/edit_message": {
"post": {
"tags": [
"Session Management"
],
"operationId": "edit_message",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Unique identifier for the session",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditMessageRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session prepared for editing - frontend should submit the edited message",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditMessageResponse"
}
}
}
},
"400": {
"description": "Bad request - Invalid message timestamp"
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"404": {
"description": "Session or message not found"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/sessions/{session_id}/export": {
"get": {
"tags": [
@@ -2560,6 +2618,39 @@
}
}
},
"EditMessageRequest": {
"type": "object",
"required": [
"timestamp"
],
"properties": {
"editType": {
"$ref": "#/components/schemas/EditType"
},
"timestamp": {
"type": "integer",
"format": "int64"
}
}
},
"EditMessageResponse": {
"type": "object",
"required": [
"sessionId"
],
"properties": {
"sessionId": {
"type": "string"
}
}
},
"EditType": {
"type": "string",
"enum": [
"fork",
"edit"
]
},
"EmbeddedResource": {
"type": "object",
"required": [
+83 -57
View File
@@ -16,10 +16,11 @@ import { ToastContainer } from 'react-toastify';
import { GoosehintsModal } from './components/GoosehintsModal';
import AnnouncementModal from './components/AnnouncementModal';
import ProviderGuard from './components/ProviderGuard';
import { createSession } from './sessions';
import { ChatType } from './types/chat';
import Hub from './components/hub';
import Pair, { PairRouteState } from './components/pair';
import Hub from './components/Hub';
import Pair, { PairRouteState } from './components/Pair';
import SettingsView, { SettingsViewOptions } from './components/settings/SettingsView';
import SessionsView from './components/sessions/SessionsView';
import SharedSessionView from './components/sessions/SharedSessionView';
@@ -37,25 +38,17 @@ import PermissionSettingsView from './components/settings/permission/PermissionS
import ExtensionsView, { ExtensionsViewOptions } from './components/extensions/ExtensionsView';
import RecipesView from './components/recipes/RecipesView';
import { View, ViewOptions } from './utils/navigationUtils';
import {
AgentState,
InitializationContext,
NoProviderOrModelError,
useAgent,
} from './hooks/useAgent';
import { NoProviderOrModelError, useAgent } from './hooks/useAgent';
import { useNavigation } from './hooks/useNavigation';
import Pair2 from './components/Pair2';
import { errorMessage } from './utils/conversionUtils';
// Route Components
const HubRouteWrapper = ({
setIsGoosehintsModalOpen,
isExtensionsLoading,
resetChat,
}: {
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
isExtensionsLoading: boolean;
resetChat: () => void;
}) => {
const setView = useNavigation();
@@ -64,7 +57,6 @@ const HubRouteWrapper = ({
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
resetChat={resetChat}
/>
);
};
@@ -73,30 +65,30 @@ const PairRouteWrapper = ({
chat,
setChat,
setIsGoosehintsModalOpen,
setAgentWaitingMessage,
setFatalError,
agentState,
loadCurrentChat,
activeSessionId,
setActiveSessionId,
}: {
chat: ChatType;
setChat: (chat: ChatType) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
setAgentWaitingMessage: (msg: string | null) => void;
setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void;
agentState: AgentState;
loadCurrentChat: (context: InitializationContext) => Promise<ChatType>;
activeSessionId: string | null;
setActiveSessionId: (id: string | null) => void;
}) => {
const location = useLocation();
const setView = useNavigation();
const routeState =
(location.state as PairRouteState) || (window.history.state as PairRouteState) || {};
const [searchParams, setSearchParams] = useSearchParams();
const initialMessage = routeState.initialMessage;
// Capture initialMessage in local state to survive route state being cleared by setSearchParams
const [capturedInitialMessage, setCapturedInitialMessage] = useState<string | undefined>(
undefined
);
const [lastSessionId, setLastSessionId] = useState<string | undefined>(undefined);
const [isCreatingSession, setIsCreatingSession] = useState(false);
const resumeSessionId = searchParams.get('resumeSessionId') ?? undefined;
const recipeId = searchParams.get('recipeId') ?? undefined;
const recipeDeeplinkFromConfig = window.appConfig?.get('recipeDeeplink') as string | undefined;
// Determine which session ID to use:
// 1. From route state (when navigating from Hub with a new session)
@@ -106,9 +98,71 @@ const PairRouteWrapper = ({
const sessionId =
routeState.resumeSessionId || resumeSessionId || activeSessionId || chat.sessionId;
// Use route state if available, otherwise use captured state
const initialMessage = routeState.initialMessage || capturedInitialMessage;
useEffect(() => {
if (routeState.initialMessage) {
setCapturedInitialMessage(routeState.initialMessage);
}
}, [routeState.initialMessage]);
useEffect(() => {
// Create a new session if we have an initialMessage, recipeId, or recipeDeeplink from config but no sessionId
if (
(initialMessage || recipeId || recipeDeeplinkFromConfig) &&
!sessionId &&
!isCreatingSession
) {
console.log(
'[PairRouteWrapper] Creating new session for initialMessage, recipeId, or recipeDeeplink from config'
);
setIsCreatingSession(true);
(async () => {
try {
const newSession = await createSession({
recipeId,
recipeDeeplink: recipeDeeplinkFromConfig,
});
setSearchParams((prev) => {
prev.set('resumeSessionId', newSession.id);
// Remove recipeId from URL after session is created
prev.delete('recipeId');
return prev;
});
setActiveSessionId(newSession.id);
} catch (error) {
console.error('[PairRouteWrapper] Failed to create session:', error);
} finally {
setIsCreatingSession(false);
}
})();
}
}, [
initialMessage,
recipeId,
recipeDeeplinkFromConfig,
sessionId,
isCreatingSession,
setSearchParams,
setActiveSessionId,
]);
// Clear captured initialMessage when sessionId actually changes to a different session
useEffect(() => {
if (sessionId !== lastSessionId) {
setLastSessionId(sessionId);
if (!routeState.initialMessage) {
setCapturedInitialMessage(undefined);
}
}
}, [sessionId, lastSessionId, routeState.initialMessage]);
// Update URL with session ID when on /pair route (for refresh support)
useEffect(() => {
if (process.env.ALPHA && sessionId && sessionId !== resumeSessionId) {
if (sessionId && sessionId !== resumeSessionId) {
setSearchParams((prev) => {
prev.set('resumeSessionId', sessionId);
return prev;
@@ -118,31 +172,19 @@ const PairRouteWrapper = ({
// Update active session state when session ID changes
useEffect(() => {
if (process.env.ALPHA && sessionId && sessionId !== activeSessionId) {
if (sessionId && sessionId !== activeSessionId) {
setActiveSessionId(sessionId);
}
}, [sessionId, activeSessionId, setActiveSessionId]);
return process.env.ALPHA ? (
<Pair2
return (
<Pair
key={sessionId}
setChat={setChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
sessionId={sessionId}
initialMessage={initialMessage}
/>
) : (
<Pair
chat={chat}
setChat={setChat}
setView={setView}
agentState={agentState}
loadCurrentChat={loadCurrentChat}
setFatalError={setFatalError}
setAgentWaitingMessage={setAgentWaitingMessage}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
resumeSessionId={resumeSessionId}
initialMessage={initialMessage}
/>
);
};
@@ -323,9 +365,7 @@ export function AppInner() {
const navigate = useNavigate();
const setView = useNavigation();
const location = useLocation();
const [_searchParams, setSearchParams] = useSearchParams();
const [chat, setChat] = useState<ChatType>({
sessionId: '',
@@ -339,16 +379,7 @@ export function AppInner() {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const { addExtension } = useConfig();
const { agentState, loadCurrentChat, resetChat } = useAgent();
const resetChatIfNecessary = useCallback(() => {
if (chat.messages.length > 0) {
setSearchParams((prev) => {
prev.delete('resumeSessionId');
return prev;
});
resetChat();
}
}, [chat.messages.length, setSearchParams, resetChat]);
const { loadCurrentChat } = useAgent();
useEffect(() => {
console.log('Sending reactReady signal to Electron');
@@ -382,7 +413,7 @@ export function AppInner() {
}
})();
}
}, [resetChat, loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]);
}, [loadCurrentChat, setAgentWaitingMessage, navigate, loadingHub, setChat]);
useEffect(() => {
const handleOpenSharedSession = async (_event: IpcRendererEvent, ...args: unknown[]) => {
@@ -632,7 +663,6 @@ export function AppInner() {
<HubRouteWrapper
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
isExtensionsLoading={isExtensionsLoading}
resetChat={resetChatIfNecessary}
/>
}
/>
@@ -642,10 +672,6 @@ export function AppInner() {
<PairRouteWrapper
chat={chat}
setChat={setChat}
agentState={agentState}
loadCurrentChat={loadCurrentChat}
setFatalError={setFatalError}
setAgentWaitingMessage={setAgentWaitingMessage}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
activeSessionId={activeSessionId}
setActiveSessionId={setActiveSessionId}
+12 -1
View File
@@ -2,7 +2,7 @@
import type { Client, Options as Options2, TDataShape } from './client';
import { client } from './client.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
import type { AddExtensionData, AddExtensionErrors, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponses, BackupConfigData, BackupConfigErrors, BackupConfigResponses, CheckProviderData, ConfirmPermissionData, ConfirmPermissionErrors, ConfirmPermissionResponses, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleResponses, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponses, EditMessageData, EditMessageErrors, EditMessageResponses, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponses, GetSessionData, GetSessionErrors, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponses, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsResponses, ImportSessionData, ImportSessionErrors, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponses, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponses, KillRunningJobData, KillRunningJobResponses, ListRecipesData, ListRecipesErrors, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponses, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, ParseRecipeData, ParseRecipeErrors, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponses, ProvidersData, ProvidersResponses, ReadAllConfigData, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponses, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentResponses, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponses, SaveRecipeData, SaveRecipeErrors, SaveRecipeResponses, ScanRecipeData, ScanRecipeResponses, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeResponses, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponses, SetConfigProviderData, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, StartAgentData, StartAgentErrors, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, StatusData, StatusResponses, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionResponses, UpdateRouterToolSelectorData, UpdateRouterToolSelectorErrors, UpdateRouterToolSelectorResponses, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponses } from './types.gen';
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = Options2<TData, ThrowOnError> & {
/**
@@ -558,6 +558,17 @@ export const getSession = <ThrowOnError extends boolean = false>(options: Option
});
};
export const editMessage = <ThrowOnError extends boolean = false>(options: Options<EditMessageData, ThrowOnError>) => {
return (options.client ?? client).post<EditMessageResponses, EditMessageErrors, ThrowOnError>({
url: '/sessions/{session_id}/edit_message',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const exportSession = <ThrowOnError extends boolean = false>(options: Options<ExportSessionData, ThrowOnError>) => {
return (options.client ?? client).get<ExportSessionResponses, ExportSessionErrors, ThrowOnError>({
url: '/sessions/{session_id}/export',
+51
View File
@@ -123,6 +123,17 @@ export type DeleteRecipeRequest = {
id: string;
};
export type EditMessageRequest = {
editType?: EditType;
timestamp: number;
};
export type EditMessageResponse = {
sessionId: string;
};
export type EditType = 'fork' | 'edit';
export type EmbeddedResource = {
_meta?: {
[key: string]: unknown;
@@ -2550,6 +2561,46 @@ export type GetSessionResponses = {
export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses];
export type EditMessageData = {
body: EditMessageRequest;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}/edit_message';
};
export type EditMessageErrors = {
/**
* Bad request - Invalid message timestamp
*/
400: unknown;
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session or message not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type EditMessageResponses = {
/**
* Session prepared for editing - frontend should submit the edited message
*/
200: EditMessageResponse;
};
export type EditMessageResponse2 = EditMessageResponses[keyof EditMessageResponses];
export type ExportSessionData = {
body?: never;
path: {
+298 -334
View File
@@ -1,232 +1,189 @@
/**
* BaseChat Component
*
* BaseChat is the foundational chat component that provides the core conversational interface
* for the Goose Desktop application. It serves as the shared base for both Hub and Pair components,
* offering a flexible and extensible chat experience.
*
* Key Responsibilities:
* - Manages the complete chat lifecycle (messages, input, submission, responses)
* - Handles file drag-and-drop functionality with preview generation
* - Integrates with multiple specialized hooks for chat engine, recipes, sessions, etc.
* - Provides context management and session summarization capabilities
* - Supports both user and assistant message rendering with tool call integration
* - Manages loading states, error handling, and retry functionality
* - Offers customization points through render props and configuration options
*
* Architecture:
* - Uses a provider pattern (ChatContextManagerProvider) for state management
* - Leverages composition through render props for flexible UI customization
* - Integrates with multiple custom hooks for separation of concerns:
* - useChatEngine: Core chat functionality and API integration
* - useRecipeManager: Recipe/agent configuration management
* - useFileDrop: Drag-and-drop file handling with previews
* - useCostTracking: Token usage and cost calculation
*
* Customization Points:
* - renderHeader(): Custom header content (used by Hub for insights/recipe controls)
* - renderBeforeMessages(): Content before message list (used by Hub for SessionInsights)
* - renderAfterMessages(): Content after message list
* - customChatInputProps: Props passed to ChatInput for specialized behavior
* - customMainLayoutProps: Props passed to MainPanelLayout
* - contentClassName: Custom CSS classes for the content area
*
* File Handling:
* - Supports drag-and-drop of files with visual feedback
* - Generates image previews for supported file types
* - Integrates dropped files with chat input for seamless attachment
* - Uses data-drop-zone="true" to designate safe drop areas
*
* The component is designed to be the single source of truth for chat functionality
* while remaining flexible enough to support different UI contexts (Hub vs Pair).
*/
import React, { createContext, useContext, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import React, {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import { RecipeHeader } from './RecipeHeader';
import LoadingGoose from './LoadingGoose';
import { getThinkingMessage } from '../types/message';
import RecipeActivities from './recipes/RecipeActivities';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { View, ViewOptions } from '../utils/navigationUtils';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
import ParameterInputModal from './ParameterInputModal';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { useChatEngine } from '../hooks/useChatEngine';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useFileDrop } from '../hooks/useFileDrop';
import { useCostTracking } from '../hooks/useCostTracking';
import { Message } from '../api';
import { ChatState } from '../types/chatState';
import { ChatType } from '../types/chat';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { cn } from '../utils';
import { useChatStream } from '../hooks/useChatStream';
import { useNavigation } from '../hooks/useNavigation';
import { RecipeHeader } from './RecipeHeader';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
import { scanRecipe } from '../recipe';
import { useCostTracking } from '../hooks/useCostTracking';
import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount';
import { Message } from '../api';
import { getThinkingMessage, getTextContent } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
import { substituteParameters } from '../utils/providerUtils';
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
import { toastSuccess } from '../toasts';
import { Recipe } from '../recipe';
// Context for sharing current model info
const CurrentModelContext = createContext<{ model: string; mode: string } | null>(null);
export const useCurrentModelInfo = () => useContext(CurrentModelContext);
interface BaseChatProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
onMessageStreamFinish?: () => void;
onMessageSubmit?: (message: string) => void;
renderHeader?: () => React.ReactNode;
renderBeforeMessages?: () => React.ReactNode;
renderAfterMessages?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string;
disableSearch?: boolean;
showPopularTopics?: boolean;
suppressEmptyState?: boolean;
autoSubmit?: boolean;
loadingChat: boolean;
suppressEmptyState: boolean;
sessionId: string;
initialMessage?: string;
}
function BaseChatContent({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
onMessageStreamFinish,
onMessageSubmit,
renderHeader,
renderBeforeMessages,
renderAfterMessages,
customChatInputProps = {},
customMainLayoutProps = {},
contentClassName = '',
disableSearch = false,
showPopularTopics = false,
suppressEmptyState = false,
autoSubmit = false,
loadingChat = false,
sessionId,
initialMessage,
}: BaseChatProps) {
const location = useLocation();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const scrollRef = useRef<ScrollAreaHandle>(null);
const disableAnimation = location.state?.disableAnimation || false;
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [currentRecipeTitle, setCurrentRecipeTitle] = React.useState<string | null>(null);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = useState<boolean>();
const [hasRecipeSecurityWarnings, setHasRecipeSecurityWarnings] = useState(false);
// Use shared chat engine
const {
messages,
filteredMessages,
append,
chatState,
error,
setMessages,
input,
handleSubmit: engineHandleSubmit,
onStopGoose,
sessionTokenCount,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
tokenState,
commandHistory,
toolCallNotifications,
sessionMetadata,
isUserMessage,
clearError,
onMessageUpdate,
} = useChatEngine({
chat,
setChat,
onMessageStreamFinish: () => {
// Call the original callback if provided
onMessageStreamFinish?.();
},
onMessageSent: () => {
// Mark that user has started using the recipe
if (recipe) {
setHasStartedUsingRecipe(true);
}
},
});
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
const setView = useNavigation();
// Use shared recipe manager
const {
recipe,
recipeId,
recipeParameterValues,
filteredParameters,
initialPrompt,
isParameterModalOpen,
setIsParameterModalOpen,
handleParameterSubmit,
handleAutoExecution,
isRecipeWarningModalOpen,
recipeAccepted,
handleRecipeAccept,
handleRecipeCancel,
hasSecurityWarnings,
isCreateRecipeModalOpen,
setIsCreateRecipeModalOpen,
handleRecipeCreated,
} = useRecipeManager(chat, location.state?.recipe);
// Reset recipe usage tracking when recipe changes
useEffect(() => {
const previousTitle = currentRecipeTitle;
const newTitle = recipe?.title || null;
const hasRecipeChanged = newTitle !== currentRecipeTitle;
if (hasRecipeChanged) {
setCurrentRecipeTitle(newTitle);
const isSwitchingBetweenRecipes = previousTitle && newTitle;
const isInitialRecipeLoad = !previousTitle && newTitle && messages.length === 0;
const hasExistingConversation = newTitle && messages.length > 0;
if (isSwitchingBetweenRecipes) {
console.log('Switching from recipe:', previousTitle, 'to:', newTitle);
setHasStartedUsingRecipe(false);
setMessages([]);
} else if (isInitialRecipeLoad) {
setHasStartedUsingRecipe(false);
} else if (hasExistingConversation) {
setHasStartedUsingRecipe(true);
}
}
}, [recipe?.title, currentRecipeTitle, messages.length, setMessages]);
// Handle recipe auto-execution
useEffect(() => {
const isProcessingResponse =
chatState !== ChatState.Idle && chatState !== ChatState.WaitingForUserInput;
handleAutoExecution(append, isProcessingResponse, () => {
setHasStartedUsingRecipe(true);
});
}, [handleAutoExecution, append, chatState]);
const contentClassName = cn('pr-1 pb-10', (isMobile || sidebarState === 'collapsed') && 'pt-11');
// Use shared file drop
const { droppedFiles, setDroppedFiles, handleDrop, handleDragOver } = useFileDrop();
// Use shared cost tracking
const { sessionCosts } = useCostTracking({
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
session: sessionMetadata,
const onStreamFinish = useCallback(() => {}, []);
const [isCreateRecipeModalOpen, setIsCreateRecipeModalOpen] = useState(false);
const hasAutoSubmittedRef = useRef(false);
// Reset auto-submit flag when session changes
useEffect(() => {
hasAutoSubmittedRef.current = false;
}, [sessionId]);
const {
session,
messages,
chatState,
handleSubmit,
stopStreaming,
sessionLoadError,
setRecipeUserParams,
tokenState,
notifications: toolCallNotifications,
onMessageUpdate,
} = useChatStream({
sessionId,
onStreamFinish,
});
// Generate command history from user messages (most recent first)
const commandHistory = useMemo(() => {
return messages
.reduce<string[]>((history, message) => {
if (message.role === 'user') {
const text = getTextContent(message).trim();
if (text) {
history.push(text);
}
}
return history;
}, [])
.reverse();
}, [messages]);
useEffect(() => {
window.electron.logInfo(
'Initial messages when resuming session: ' + JSON.stringify(chat.messages, null, 2)
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (!session || hasAutoSubmittedRef.current) {
return;
}
const shouldStartAgent = searchParams.get('shouldStartAgent') === 'true';
if (initialMessage) {
// Submit the initial message (e.g., from fork)
hasAutoSubmittedRef.current = true;
handleSubmit(initialMessage);
} else if (shouldStartAgent) {
// Trigger agent to continue with existing conversation
hasAutoSubmittedRef.current = true;
handleSubmit('');
}
}, [session, initialMessage, searchParams, handleSubmit]);
const handleFormSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const textValue = customEvent.detail?.value || '';
if (recipe && textValue.trim()) {
setHasStartedUsingRecipe(true);
}
handleSubmit(textValue);
};
const { sessionCosts } = useCostTracking({
sessionInputTokens: session?.accumulated_input_tokens || 0,
sessionOutputTokens: session?.accumulated_output_tokens || 0,
localInputTokens: 0,
localOutputTokens: 0,
session,
});
const recipe = session?.recipe;
useEffect(() => {
if (!recipe) return;
(async () => {
const accepted = await window.electron.hasAcceptedRecipeBefore(recipe);
setHasNotAcceptedRecipe(!accepted);
if (!accepted) {
const scanResult = await scanRecipe(recipe);
setHasRecipeSecurityWarnings(scanResult.has_security_warnings);
}
})();
}, [recipe]);
const handleRecipeAccept = async (accept: boolean) => {
if (recipe && accept) {
await window.electron.recordRecipeHash(recipe);
setHasNotAcceptedRecipe(false);
} else {
setView('chat');
}
};
// Track if this is the initial render for session resuming
const initialRenderRef = useRef(true);
@@ -246,34 +203,7 @@ function BaseChatContent({
}
}, [messages.length]);
// Handle submit
const handleSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
// Mark that user has started using the recipe when they submit a message
if (recipe && combinedTextFromInput.trim()) {
setHasStartedUsingRecipe(true);
}
// Call the callback if provided (for Hub to handle navigation)
if (onMessageSubmit && combinedTextFromInput.trim()) {
onMessageSubmit(combinedTextFromInput);
}
engineHandleSubmit(combinedTextFromInput);
};
const toolCount = useToolCount(chat.sessionId);
// Wrapper for append that tracks recipe usage
const appendWithTracking = (text: string | Message) => {
// Mark that user has started using the recipe when they use append
if (recipe) {
setHasStartedUsingRecipe(true);
}
append(text);
};
const toolCount = useToolCount(sessionId);
// Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions)
useEffect(() => {
@@ -290,6 +220,121 @@ function BaseChatContent({
return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
}, []);
useEffect(() => {
const handleMakeAgent = () => {
setIsCreateRecipeModalOpen(true);
};
window.addEventListener('make-agent-from-chat', handleMakeAgent);
return () => window.removeEventListener('make-agent-from-chat', handleMakeAgent);
}, []);
useEffect(() => {
const handleSessionForked = (event: Event) => {
const customEvent = event as CustomEvent<{
newSessionId: string;
shouldStartAgent?: boolean;
editedMessage?: string;
}>;
const { newSessionId, shouldStartAgent, editedMessage } = customEvent.detail;
const params = new URLSearchParams();
params.set('resumeSessionId', newSessionId);
if (shouldStartAgent) {
params.set('shouldStartAgent', 'true');
}
navigate(`/pair?${params.toString()}`, {
state: {
disableAnimation: true,
initialMessage: editedMessage,
},
});
};
window.addEventListener('session-forked', handleSessionForked);
return () => {
window.removeEventListener('session-forked', handleSessionForked);
};
}, [location.pathname, navigate]);
const handleRecipeCreated = (recipe: Recipe) => {
toastSuccess({
title: 'Recipe created successfully!',
msg: `"${recipe.title}" has been saved and is ready to use.`,
});
};
const renderProgressiveMessageList = (chat: ChatType) => (
<>
<ProgressiveMessageList
messages={messages}
chat={chat}
toolCallNotifications={toolCallNotifications}
isUserMessage={(m: Message) => m.role === 'user'}
isStreamingMessage={chatState !== ChatState.Idle}
onRenderingComplete={handleRenderingComplete}
onMessageUpdate={onMessageUpdate}
/>
</>
);
const showPopularTopics =
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
const chat: ChatType = {
messageHistoryIndex: 0,
messages,
recipe,
sessionId,
name: session?.name || 'No Session',
};
// Only use initialMessage for the prompt if it hasn't been submitted yet
// If we have a recipe prompt and user recipe values, substitute parameters
let recipePrompt = '';
if (messages.length === 0 && recipe?.prompt) {
recipePrompt = session?.user_recipe_values
? substituteParameters(recipe.prompt, session.user_recipe_values)
: recipe.prompt;
}
const initialPrompt =
(initialMessage && !hasAutoSubmittedRef.current ? initialMessage : '') || recipePrompt;
if (sessionLoadError) {
return (
<div className="h-full flex flex-col min-h-0">
<MainPanelLayout
backgroundColor={'bg-background-muted'}
removeTopPadding={true}
{...customMainLayoutProps}
>
{renderHeader && renderHeader()}
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
<div className="flex-1 bg-background-default rounded-b-2xl flex items-center justify-center">
<div className="flex flex-col items-center justify-center p-8">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
<p className="text-sm">{sessionLoadError}</p>
</div>
<button
onClick={() => {
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"
>
Go home
</button>
</div>
</div>
</div>
</MainPanelLayout>
</div>
);
}
return (
<div className="h-full flex flex-col min-h-0">
<MainPanelLayout
@@ -312,121 +357,44 @@ 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">
<RecipeHeader title={recipe.title} />
</div>
)}
{/* Custom content before messages */}
{renderBeforeMessages && renderBeforeMessages()}
{/* Recipe Activities - always show when recipe is active and accepted */}
{recipe && recipeAccepted && !suppressEmptyState && (
{recipe && (
<div className={hasStartedUsingRecipe ? 'mb-6' : ''}>
<RecipeActivities
append={(text: string) => appendWithTracking(text)}
append={(text: string) => handleSubmit(text)}
activities={Array.isArray(recipe.activities) ? recipe.activities : null}
title={recipe.title}
parameterValues={recipeParameterValues || {}}
//parameterValues={recipeParameters || {}}
/>
</div>
)}
{/* Messages or Popular Topics */}
{
loadingChat ? null : filteredMessages.length > 0 ||
(recipe && recipeAccepted && hasStartedUsingRecipe) ? (
<>
{disableSearch ? (
// Render messages without SearchView wrapper when search is disabled
<ProgressiveMessageList
messages={filteredMessages}
chat={chat}
toolCallNotifications={toolCallNotifications}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
isUserMessage={isUserMessage}
isStreamingMessage={chatState !== ChatState.Idle}
onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/>
) : (
// Render messages with SearchView wrapper when search is enabled
<SearchView placeholder="Search conversation...">
<ProgressiveMessageList
messages={filteredMessages}
chat={chat}
toolCallNotifications={toolCallNotifications}
append={append}
appendMessage={(newMessage) => {
const updatedMessages = [...messages, newMessage];
setMessages(updatedMessages);
}}
isUserMessage={isUserMessage}
isStreamingMessage={chatState !== ChatState.Idle}
onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/>
</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 button to retry last message */}
<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();
// 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 */}
{(chatState !== ChatState.Idle || loadingChat) && (
{chatState !== ChatState.Idle && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
message={
loadingChat
? 'loading conversation...'
: getThinkingMessage(messages[messages.length - 1])
}
chatState={chatState}
message={
messages.length > 0
? getThinkingMessage(messages[messages.length - 1])
: undefined
}
/>
</div>
)}
@@ -436,19 +404,19 @@ function BaseChatContent({
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
sessionId={chat.sessionId}
handleSubmit={handleSubmit}
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
onStop={onStopGoose}
onStop={stopStreaming}
commandHistory={commandHistory}
initialValue={input || ''}
initialValue={initialPrompt}
setView={setView}
totalTokens={tokenState?.totalTokens || sessionTokenCount}
totalTokens={tokenState?.totalTokens ?? session?.total_tokens ?? undefined}
accumulatedInputTokens={
tokenState?.accumulatedInputTokens || sessionInputTokens || localInputTokens
tokenState?.accumulatedInputTokens ?? session?.accumulated_input_tokens ?? undefined
}
accumulatedOutputTokens={
tokenState?.accumulatedOutputTokens || sessionOutputTokens || localOutputTokens
tokenState?.accumulatedOutputTokens ?? session?.accumulated_output_tokens ?? undefined
}
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
@@ -457,40 +425,36 @@ function BaseChatContent({
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
recipeId={recipeId}
recipeAccepted={recipeAccepted}
recipeAccepted={!hasNotAcceptedRecipe}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
autoSubmit={autoSubmit}
append={append}
{...customChatInputProps}
/>
</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 Parameter Modal */}
{isParameterModalOpen && filteredParameters.length > 0 && (
<ParameterInputModal
parameters={filteredParameters}
onSubmit={handleParameterSubmit}
onClose={() => setIsParameterModalOpen(false)}
{recipe && (
<RecipeWarningModal
isOpen={!!hasNotAcceptedRecipe}
onConfirm={() => handleRecipeAccept(true)}
onCancel={() => handleRecipeAccept(false)}
recipeDetails={{
title: recipe.title,
description: recipe.description,
instructions: recipe.instructions || undefined,
}}
hasSecurityWarnings={hasRecipeSecurityWarnings}
/>
)}
{recipe?.parameters && recipe.parameters.length > 0 && !session?.user_recipe_values && (
<ParameterInputModal
parameters={recipe.parameters}
onSubmit={setRecipeUserParams}
onClose={() => setView('chat')}
/>
)}
{/* Create Recipe from Session Modal */}
<CreateRecipeFromSessionModal
isOpen={isCreateRecipeModalOpen}
onClose={() => setIsCreateRecipeModalOpen(false)}
-350
View File
@@ -1,350 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { SearchView } from './conversation/SearchView';
import LoadingGoose from './LoadingGoose';
import PopularChatTopics from './PopularChatTopics';
import ProgressiveMessageList from './ProgressiveMessageList';
import { MainPanelLayout } from './Layout/MainPanelLayout';
import ChatInput from './ChatInput';
import { ScrollArea, ScrollAreaHandle } from './ui/scroll-area';
import { useFileDrop } from '../hooks/useFileDrop';
import { Message } from '../api';
import { ChatState } from '../types/chatState';
import { ChatType } from '../types/chat';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { cn } from '../utils';
import { useChatStream } from '../hooks/useChatStream';
import { useNavigation } from '../hooks/useNavigation';
import { RecipeHeader } from './RecipeHeader';
import { RecipeWarningModal } from './ui/RecipeWarningModal';
import { scanRecipe } from '../recipe';
import { useCostTracking } from '../hooks/useCostTracking';
import RecipeActivities from './recipes/RecipeActivities';
import { useToolCount } from './alerts/useToolCount';
import { getThinkingMessage } from '../types/message';
import ParameterInputModal from './ParameterInputModal';
interface BaseChatProps {
setChat: (chat: ChatType) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
onMessageSubmit?: (message: string) => void;
renderHeader?: () => React.ReactNode;
customChatInputProps?: Record<string, unknown>;
customMainLayoutProps?: Record<string, unknown>;
contentClassName?: string;
disableSearch?: boolean;
showPopularTopics?: boolean;
suppressEmptyState: boolean;
autoSubmit?: boolean;
sessionId: string;
initialMessage?: string;
}
function BaseChatContent({
setIsGoosehintsModalOpen,
renderHeader,
customChatInputProps = {},
customMainLayoutProps = {},
sessionId,
initialMessage,
autoSubmit = false,
}: BaseChatProps) {
const location = useLocation();
const scrollRef = useRef<ScrollAreaHandle>(null);
const disableAnimation = location.state?.disableAnimation || false;
const [hasStartedUsingRecipe, setHasStartedUsingRecipe] = React.useState(false);
const [hasNotAcceptedRecipe, setHasNotAcceptedRecipe] = 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();
const onStreamFinish = useCallback(() => {}, []);
const {
session,
messages,
chatState,
handleSubmit,
stopStreaming,
sessionLoadError,
setRecipeUserParams,
tokenState,
} = useChatStream({
sessionId,
onStreamFinish,
initialMessage,
});
const handleFormSubmit = (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const textValue = customEvent.detail?.value || '';
if (recipe && textValue.trim()) {
setHasStartedUsingRecipe(true);
}
handleSubmit(textValue);
};
const { sessionCosts } = useCostTracking({
sessionInputTokens: session?.accumulated_input_tokens || 0,
sessionOutputTokens: session?.accumulated_output_tokens || 0,
localInputTokens: 0,
localOutputTokens: 0,
session,
});
const recipe = session?.recipe;
useEffect(() => {
if (!recipe) return;
(async () => {
const accepted = await window.electron.hasAcceptedRecipeBefore(recipe);
setHasNotAcceptedRecipe(!accepted);
if (!accepted) {
const scanResult = await scanRecipe(recipe);
setHasRecipeSecurityWarnings(scanResult.has_security_warnings);
}
})();
}, [recipe]);
const handleRecipeAccept = async (accept: boolean) => {
if (recipe && accept) {
await window.electron.recordRecipeHash(recipe);
setHasNotAcceptedRecipe(false);
} else {
setView('chat');
}
};
// Track if this is the initial render for session resuming
const initialRenderRef = useRef(true);
// Auto-scroll when messages are loaded (for session resuming)
const handleRenderingComplete = React.useCallback(() => {
// Only force scroll on the very first render
if (initialRenderRef.current && messages.length > 0) {
initialRenderRef.current = false;
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
} else if (scrollRef.current?.isFollowing) {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}
}, [messages.length]);
const toolCount = useToolCount(sessionId);
// Listen for global scroll-to-bottom requests (e.g., from MCP UI prompt actions)
useEffect(() => {
const handleGlobalScrollRequest = () => {
// Add a small delay to ensure content has been rendered
setTimeout(() => {
if (scrollRef.current?.scrollToBottom) {
scrollRef.current.scrollToBottom();
}
}, 200);
};
window.addEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
return () => window.removeEventListener('scroll-chat-to-bottom', handleGlobalScrollRequest);
}, []);
const renderProgressiveMessageList = (chat: ChatType) => (
<>
<ProgressiveMessageList
messages={messages}
chat={chat}
// toolCallNotifications={toolCallNotifications}
// appendMessage={(newMessage) => {
// const updatedMessages = [...messages, newMessage];
// setMessages(updatedMessages);
// }}
isUserMessage={(m: Message) => m.role === 'user'}
isStreamingMessage={chatState !== ChatState.Idle}
// onMessageUpdate={onMessageUpdate}
onRenderingComplete={handleRenderingComplete}
/>
</>
);
const showPopularTopics =
messages.length === 0 && !initialMessage && chatState === ChatState.Idle;
const chat: ChatType = {
messageHistoryIndex: 0,
messages,
recipe,
sessionId,
name: session?.name || 'No Session',
};
const initialPrompt =
initialMessage || (messages.length == 0 && recipe?.prompt ? recipe.prompt : '');
const shouldAutoSubmit = autoSubmit || !!initialMessage;
return (
<div className="h-full flex flex-col min-h-0">
<h2>Warning: BaseChat2!</h2>
<MainPanelLayout
backgroundColor={'bg-background-muted'}
removeTopPadding={true}
{...customMainLayoutProps}
>
{/* Custom header */}
{renderHeader && renderHeader()}
{/* Chat container with sticky recipe header */}
<div className="flex flex-col flex-1 mb-0.5 min-h-0 relative">
<ScrollArea
ref={scrollRef}
className={`flex-1 bg-background-default rounded-b-2xl min-h-0 relative ${contentClassName}`}
autoScroll
onDrop={handleDrop}
onDragOver={handleDragOver}
data-drop-zone="true"
paddingX={6}
paddingY={0}
>
{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>
)}
{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">
<div className="text-red-700 dark:text-red-300 bg-red-400/50 p-4 rounded-lg mb-4 max-w-md">
<h3 className="font-semibold mb-2">Failed to Load Session</h3>
<p className="text-sm">{sessionLoadError}</p>
</div>
<button
onClick={() => {
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"
>
Go home
</button>
</div>
)}
{/* Messages or Popular Topics */}
{messages.length > 0 || recipe ? (
<>
<SearchView>{renderProgressiveMessageList(chat)}</SearchView>
<div className="block h-8" />
</>
) : !recipe && showPopularTopics ? (
<PopularChatTopics append={(text: string) => handleSubmit(text)} />
) : null}
</ScrollArea>
{chatState !== ChatState.Idle && !sessionLoadError && (
<div className="absolute bottom-1 left-4 z-20 pointer-events-none">
<LoadingGoose
chatState={chatState}
message={
messages.length > 0
? getThinkingMessage(messages[messages.length - 1])
: undefined
}
/>
</div>
)}
</div>
<div
className={`relative z-10 ${disableAnimation ? '' : 'animate-[fadein_400ms_ease-in_forwards]'}`}
>
<ChatInput
sessionId={sessionId}
handleSubmit={handleFormSubmit}
chatState={chatState}
onStop={stopStreaming}
//commandHistory={commandHistory}
initialValue={initialPrompt}
setView={setView}
totalTokens={tokenState?.totalTokens ?? session?.total_tokens ?? undefined}
accumulatedInputTokens={
tokenState?.accumulatedInputTokens ?? session?.accumulated_input_tokens ?? undefined
}
accumulatedOutputTokens={
tokenState?.accumulatedOutputTokens ?? session?.accumulated_output_tokens ?? undefined
}
droppedFiles={droppedFiles}
onFilesProcessed={() => setDroppedFiles([])} // Clear dropped files after processing
messages={messages}
disableAnimation={disableAnimation}
sessionCosts={sessionCosts}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
recipe={recipe}
recipeAccepted={!hasNotAcceptedRecipe}
initialPrompt={initialPrompt}
toolCount={toolCount || 0}
autoSubmit={shouldAutoSubmit}
{...customChatInputProps}
/>
</div>
</MainPanelLayout>
{recipe && (
<RecipeWarningModal
isOpen={!!hasNotAcceptedRecipe}
onConfirm={() => handleRecipeAccept(true)}
onCancel={() => handleRecipeAccept(false)}
recipeDetails={{
title: recipe.title,
description: recipe.description,
instructions: recipe.instructions || undefined,
}}
hasSecurityWarnings={hasRecipeSecurityWarnings}
/>
)}
{recipe?.parameters && recipe.parameters.length > 0 && !session?.user_recipe_values && (
<ParameterInputModal
parameters={recipe.parameters}
onSubmit={setRecipeUserParams}
onClose={() => setView('chat')}
/>
)}
{/*/!* Create Recipe from Session Modal *!/*/}
{/*<CreateRecipeFromSessionModal*/}
{/* isOpen={isCreateRecipeModalOpen}*/}
{/* onClose={() => setIsCreateRecipeModalOpen(false)}*/}
{/* sessionId={chat.sessionId}*/}
{/* onRecipeCreated={handleRecipeCreated}*/}
{/*/>*/}
</div>
);
}
export default function BaseChat(props: BaseChatProps) {
return <BaseChatContent {...props} />;
}
+3 -13
View File
@@ -64,10 +64,10 @@ interface ChatInputProps {
handleSubmit: (e: React.FormEvent) => void;
chatState: ChatState;
onStop?: () => void;
commandHistory?: string[]; // Current chat's message history
commandHistory?: string[];
initialValue?: string;
droppedFiles?: DroppedFile[];
onFilesProcessed?: () => void; // Callback to clear dropped files after processing
onFilesProcessed?: () => void;
setView: (view: View) => void;
totalTokens?: number;
accumulatedInputTokens?: number;
@@ -87,7 +87,6 @@ interface ChatInputProps {
recipeAccepted?: boolean;
initialPrompt?: string;
toolCount: number;
autoSubmit: boolean;
append?: (message: Message) => void;
isExtensionsLoading?: boolean;
}
@@ -114,7 +113,6 @@ export default function ChatInput({
recipeAccepted,
initialPrompt,
toolCount,
autoSubmit = false,
append: _append,
isExtensionsLoading = false,
}: ChatInputProps) {
@@ -307,7 +305,6 @@ export default function ChatInput({
const [hasUserTyped, setHasUserTyped] = useState(false);
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const timeoutRefsRef = useRef<Set<ReturnType<typeof setTimeout>>>(new Set());
const [didAutoSubmit, setDidAutoSubmit] = useState<boolean>(false);
// Use shared file drop hook for ChatInput
const {
@@ -939,13 +936,6 @@ export default function ChatInput({
]
);
useEffect(() => {
if (!!autoSubmit && !didAutoSubmit) {
setDidAutoSubmit(true);
performSubmit(initialValue);
}
}, [autoSubmit, didAutoSubmit, initialValue, performSubmit]);
const handleKeyDown = (evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
// If mention popover is open, handle arrow keys and enter
if (mentionPopover.isOpen && mentionPopoverRef.current) {
@@ -1504,7 +1494,7 @@ export default function ChatInput({
</Tooltip>
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuModeSelection />
{process.env.ALPHA && sessionId && (
{sessionId && (
<>
<div className="w-px h-4 bg-border-default mx-2" />
<BottomMenuExtensionSelection sessionId={sessionId} />
+22 -9
View File
@@ -9,12 +9,11 @@ import {
getToolRequests,
getToolResponses,
getToolConfirmationContent,
createToolErrorResponseMessage,
NotificationEvent,
} from '../types/message';
import { Message } from '../api';
import { Message, confirmPermission } from '../api';
import ToolCallConfirmation from './ToolCallConfirmation';
import MessageCopyLink from './MessageCopyLink';
import { NotificationEvent } from '../hooks/useMessageStream';
import { cn } from '../utils';
import { identifyConsecutiveToolCalls, shouldHideTimestamp } from '../utils/toolCallChaining';
@@ -28,7 +27,6 @@ interface GooseMessageProps {
metadata?: string[];
toolCallNotifications: Map<string, NotificationEvent[]>;
append: (value: string) => void;
appendMessage: (message: Message) => void;
isStreaming?: boolean; // Whether this message is currently being streamed
}
@@ -39,7 +37,6 @@ export default function GooseMessage({
messages,
toolCallNotifications,
append,
appendMessage,
isStreaming = false,
}: GooseMessageProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
@@ -112,9 +109,25 @@ export default function GooseMessage({
if (!hasExistingResponse) {
handledToolConfirmations.current.add(toolConfirmationContent.id);
appendMessage(
createToolErrorResponseMessage(toolConfirmationContent.id, 'The tool call is cancelled.')
);
void (async () => {
try {
await confirmPermission({
body: {
session_id: sessionId,
id: toolConfirmationContent.id,
action: 'deny',
},
throwOnError: true,
});
} catch (error) {
console.error('Failed to send tool cancellation to backend:', error);
const { toastError } = await import('../toasts');
toastError({
title: 'Failed to cancel tool',
msg: 'The agent may be waiting for a response. Please try restarting the session.',
});
}
})();
}
}
}, [
@@ -123,7 +136,7 @@ export default function GooseMessage({
hasToolConfirmation,
toolConfirmationContent,
messages,
appendMessage,
sessionId,
]);
return (
@@ -107,7 +107,7 @@ export function GroupedExtensionLoadingToast({
size="sm"
onClick={(e) => {
e.stopPropagation();
startNewSession(ext.recoverHints, null, setView);
startNewSession(ext.recoverHints, setView);
}}
className="self-start"
>
@@ -25,19 +25,17 @@ export default function Hub({
setView,
setIsGoosehintsModalOpen,
isExtensionsLoading,
resetChat,
}: {
setView: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
isExtensionsLoading: boolean;
resetChat: () => void;
}) {
const handleSubmit = async (e: React.FormEvent) => {
const customEvent = e as unknown as CustomEvent;
const combinedTextFromInput = customEvent.detail?.value || '';
if (combinedTextFromInput.trim()) {
await startNewSession(combinedTextFromInput, resetChat, setView);
await startNewSession(combinedTextFromInput, setView);
e.preventDefault();
}
};
@@ -51,10 +49,8 @@ export default function Hub({
<ChatInput
sessionId={null}
handleSubmit={handleSubmit}
autoSubmit={false}
chatState={ChatState.Idle}
onStop={() => {}}
commandHistory={[]}
initialValue=""
setView={setView}
totalTokens={0}
@@ -1,7 +1,12 @@
import 'react-toastify/dist/ReactToastify.css';
import { ChatType } from '../types/chat';
import BaseChat2 from './BaseChat2';
import BaseChat from './BaseChat';
export interface PairRouteState {
resumeSessionId?: string;
initialMessage?: string;
}
interface PairProps {
setChat: (chat: ChatType) => void;
@@ -17,7 +22,7 @@ export default function Pair({
initialMessage,
}: PairProps) {
return (
<BaseChat2
<BaseChat
setChat={setChat}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
sessionId={sessionId}
@@ -19,7 +19,7 @@ import { Message } from '../api';
import GooseMessage from './GooseMessage';
import UserMessage from './UserMessage';
import { SystemNotificationInline } from './context_management/SystemNotificationInline';
import { NotificationEvent } from '../hooks/useMessageStream';
import { NotificationEvent } from '../types/message';
import LoadingGoose from './LoadingGoose';
import { ChatType } from '../types/chat';
import { identifyConsecutiveToolCalls, isInChain } from '../utils/toolCallChaining';
@@ -29,7 +29,6 @@ interface ProgressiveMessageListProps {
chat?: Pick<ChatType, 'sessionId' | 'messageHistoryIndex'>;
toolCallNotifications?: Map<string, NotificationEvent[]>; // Make optional
append?: (value: string) => void; // Make optional
appendMessage?: (message: Message) => void; // Make optional
isUserMessage: (message: Message) => boolean;
batchSize?: number;
batchDelay?: number;
@@ -46,7 +45,6 @@ export default function ProgressiveMessageList({
chat,
toolCallNotifications = new Map(),
append = () => {},
appendMessage = () => {},
isUserMessage,
batchSize = 20,
batchDelay = 20,
@@ -218,7 +216,6 @@ export default function ProgressiveMessageList({
message={message}
messages={messages}
append={append}
appendMessage={appendMessage}
toolCallNotifications={toolCallNotifications}
isStreaming={
isStreamingMessage &&
@@ -239,7 +236,6 @@ export default function ProgressiveMessageList({
isUserMessage,
chat,
append,
appendMessage,
toolCallNotifications,
isStreamingMessage,
onMessageUpdate,
@@ -4,10 +4,13 @@ import React, { useEffect, useRef, useState } from 'react';
import { Button } from './ui/button';
import { ToolCallArguments, ToolCallArgumentValue } from './ToolCallArguments';
import MarkdownContent from './MarkdownContent';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../types/message';
import {
ToolRequestMessageContent,
ToolResponseMessageContent,
NotificationEvent,
} from '../types/message';
import { cn, snakeToTitleCase } from '../utils';
import { LoadingStatus } from './ui/Dot';
import { NotificationEvent } from '../hooks/useMessageStream';
import { ChevronRight, FlaskConical } from 'lucide-react';
import { TooltipWrapper } from './settings/providers/subcomponents/buttons/TooltipWrapper';
import MCPUIResourceRenderer from './MCPUIResourceRenderer';
@@ -158,7 +161,8 @@ interface Progress {
}
const logToString = (logMessage: NotificationEvent) => {
const params = logMessage.message.params;
const message = logMessage.message as { method: string; params: unknown };
const params = message.params as Record<string, unknown>;
// Special case for the developer system shell logs
if (
@@ -174,8 +178,10 @@ const logToString = (logMessage: NotificationEvent) => {
return typeof params.data === 'string' ? params.data : JSON.stringify(params.data);
};
const notificationToProgress = (notification: NotificationEvent): Progress =>
notification.message.params as unknown as Progress;
const notificationToProgress = (notification: NotificationEvent): Progress => {
const message = notification.message as { method: string; params: unknown };
return message.params as Progress;
};
// Helper function to extract extension name for tooltip
const getExtensionTooltip = (toolCallName: string): string | null => {
@@ -256,11 +262,17 @@ function ToolCallView({
: [];
const logs = notifications
?.filter((notification) => notification.message.method === 'notifications/message')
?.filter((notification) => {
const message = notification.message as { method?: string };
return message.method === 'notifications/message';
})
.map(logToString);
const progress = notifications
?.filter((notification) => notification.message.method === 'notifications/progress')
?.filter((notification) => {
const message = notification.message as { method?: string };
return message.method === 'notifications/progress';
})
.map(notificationToProgress)
.reduce((map, item) => {
const key = item.progressToken;
+38 -20
View File
@@ -11,7 +11,7 @@ import { Button } from './ui/button';
interface UserMessageProps {
message: Message;
onMessageUpdate?: (messageId: string, newContent: string) => void;
onMessageUpdate?: (messageId: string, newContent: string, editType?: 'fork' | 'edit') => void;
}
export default function UserMessage({ message, onMessageUpdate }: UserMessageProps) {
@@ -85,26 +85,26 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
window.electron.logInfo(`Content changed: ${newContent}`);
}, []);
// Handle save action
const handleSave = useCallback(() => {
// Exit edit mode immediately
setIsEditing(false);
// Check if content has actually changed
if (editContent !== displayText) {
// Validate content
const handleSave = useCallback(
(editType: 'fork' | 'edit' = 'fork') => {
if (editContent.trim().length === 0) {
setError('Message cannot be empty');
return;
}
// Update the message content through the callback
setIsEditing(false);
if (editContent.trim() === displayText.trim()) {
return;
}
if (onMessageUpdate && message.id) {
onMessageUpdate(message.id, editContent);
onMessageUpdate(message.id, editContent, editType);
setHasBeenEdited(true);
}
}
}, [editContent, displayText, onMessageUpdate, message.id]);
},
[editContent, displayText, onMessageUpdate, message.id]
);
// Handle cancel action
const handleCancel = useCallback(() => {
@@ -177,13 +177,31 @@ export default function UserMessage({ message, onMessageUpdate }: UserMessagePro
{error}
</div>
)}
<div className="flex justify-end gap-3 mt-4">
<Button onClick={handleCancel} variant="ghost" aria-label="Cancel editing">
Cancel
</Button>
<Button onClick={handleSave} aria-label="Save changes">
Save
</Button>
<div className="flex justify-between items-center mt-4">
<div className="text-xs text-text-subtle">
<span className="font-semibold">Edit in Place</span> updates this session {' '}
<span className="font-semibold">Fork Session</span> creates a new session
</div>
<div className="flex gap-3">
<Button onClick={handleCancel} variant="ghost" aria-label="Cancel editing">
Cancel
</Button>
<Button
onClick={() => handleSave('edit')}
variant="secondary"
aria-label="Edit message in place"
title="Update the message in this session"
>
Edit in Place
</Button>
<Button
onClick={() => handleSave('fork')}
aria-label="Fork session with edited message"
title="Create a new session with the edited message"
>
Fork Session
</Button>
</div>
</div>
</div>
) : (
-182
View File
@@ -1,182 +0,0 @@
import { useEffect, useState } from 'react';
import BaseChat from './BaseChat';
import { useRecipeManager } from '../hooks/useRecipeManager';
import { useIsMobile } from '../hooks/use-mobile';
import { useSidebar } from './ui/sidebar';
import { AgentState, InitializationContext } from '../hooks/useAgent';
import 'react-toastify/dist/ReactToastify.css';
import { cn } from '../utils';
import { ChatType } from '../types/chat';
import { useSearchParams } from 'react-router-dom';
import type { setViewType } from '../hooks/useNavigation';
export interface PairRouteState {
resumeSessionId?: string;
initialMessage?: string;
}
interface PairProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
setView: setViewType;
setIsGoosehintsModalOpen: (isOpen: boolean) => void;
setFatalError: (value: ((prevState: string | null) => string | null) | string | null) => void;
setAgentWaitingMessage: (msg: string | null) => void;
agentState: AgentState;
loadCurrentChat: (context: InitializationContext) => Promise<ChatType>;
}
export default function Pair({
chat,
setChat,
setView,
setIsGoosehintsModalOpen,
setFatalError,
setAgentWaitingMessage,
agentState,
loadCurrentChat,
resumeSessionId,
initialMessage,
}: PairProps & PairRouteState) {
const isMobile = useIsMobile();
const { state: sidebarState } = useSidebar();
const [hasProcessedInitialInput, setHasProcessedInitialInput] = useState(false);
const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false);
const [messageToSubmit, setMessageToSubmit] = useState<string | null>(null);
const [isTransitioningFromHub, setIsTransitioningFromHub] = useState(false);
const [loadingChat, setLoadingChat] = useState(false);
const [_searchParams, setSearchParams] = useSearchParams();
useEffect(() => {
const initializeFromState = async () => {
setLoadingChat(true);
try {
const chat = await loadCurrentChat({
resumeSessionId,
setAgentWaitingMessage,
});
setChat(chat);
setSearchParams((prev) => {
prev.set('resumeSessionId', chat.sessionId);
return prev;
});
} catch (error) {
console.error('Agent initialization failed:', error);
// Clear deleted session from URL and retry
if (
error instanceof Error &&
(error.message.includes('Session not found') || error.message.includes('404'))
) {
console.log('Clearing invalid session ID from URL');
setSearchParams((prev) => {
prev.delete('resumeSessionId');
return prev;
});
try {
const chat = await loadCurrentChat({
setAgentWaitingMessage,
});
setChat(chat);
setSearchParams((prev) => {
prev.set('resumeSessionId', chat.sessionId);
return prev;
});
} catch (retryError) {
handleInitializationError(retryError);
}
} else {
handleInitializationError(error);
}
} finally {
setLoadingChat(false);
}
};
const handleInitializationError = (error: unknown) => {
let errorMessage = 'Unknown error occurred';
if (error) {
if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'object' && error !== null) {
// Handle case where error is an object with properties
try {
errorMessage = JSON.stringify(error);
} catch {
errorMessage = Object.prototype.toString.call(error);
}
} else {
errorMessage = String(error);
}
}
setFatalError(`Agent init failure: ${errorMessage}`);
};
initializeFromState();
}, [
agentState,
setChat,
setFatalError,
setAgentWaitingMessage,
loadCurrentChat,
resumeSessionId,
setSearchParams,
]);
// Followed by sending the initialMessage if we have one. This will happen
// only once, unless we reset the chat in step one.
useEffect(() => {
if (agentState !== AgentState.INITIALIZED || !initialMessage || hasProcessedInitialInput) {
return;
}
setIsTransitioningFromHub(true);
setHasProcessedInitialInput(true);
setMessageToSubmit(initialMessage);
setShouldAutoSubmit(true);
}, [agentState, initialMessage, hasProcessedInitialInput]);
useEffect(() => {
if (agentState === AgentState.NO_PROVIDER) {
setView('welcome');
}
}, [agentState, setView]);
const { initialPrompt: recipeInitialPrompt } = useRecipeManager(chat, chat.recipe || null);
const handleMessageSubmit = (message: string) => {
// Clean up any auto submit state:
setShouldAutoSubmit(false);
setIsTransitioningFromHub(false);
setMessageToSubmit(null);
console.log('Message submitted:', message);
};
const recipePrompt =
agentState === 'initialized' && chat.messages.length === 0 && recipeInitialPrompt;
const initialValue = messageToSubmit || recipePrompt || undefined;
const customChatInputProps = {
// Pass initial message from Hub or recipe prompt
initialValue,
};
return (
<BaseChat
chat={chat}
loadingChat={loadingChat}
autoSubmit={shouldAutoSubmit}
setChat={setChat}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
onMessageSubmit={handleMessageSubmit}
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
suppressEmptyState={isTransitioningFromHub} // Suppress all empty state content while transitioning from Hub
/>
);
}
@@ -305,7 +305,6 @@ export default function CreateEditRecipeModal({
undefined,
undefined,
undefined,
undefined,
saved_recipe_id
);
@@ -195,7 +195,6 @@ export default function CreateRecipeFromSessionModal({
undefined,
undefined,
undefined,
undefined,
recipeId
);
}
@@ -10,6 +10,7 @@ import {
Link,
Clock,
Terminal,
ExternalLink,
} from 'lucide-react';
import { ScrollArea } from '../ui/scroll-area';
import { Card } from '../ui/card';
@@ -92,43 +93,37 @@ export default function RecipesView() {
}
};
const handleStartRecipeChat = async (recipe: Recipe, recipeId: string) => {
if (process.env.ALPHA) {
try {
const newAgent = await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
recipe,
},
throwOnError: true,
});
const session = newAgent.data;
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
});
} catch (error) {
console.error('Failed to load recipe:', error);
setError(error instanceof Error ? error.message : 'Failed to load recipe');
}
} else {
try {
window.electron.createChatWindow(
undefined,
undefined,
undefined,
undefined,
const handleStartRecipeChat = async (recipe: Recipe, _recipeId: string) => {
try {
const newAgent = await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
recipe,
undefined,
recipeId
);
} catch (err) {
console.error('Failed to load recipe:', err);
setError(err instanceof Error ? err.message : 'Failed to load recipe');
}
},
throwOnError: true,
});
const session = newAgent.data;
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
});
} catch (error) {
console.error('Failed to load recipe:', error);
setError(error instanceof Error ? error.message : 'Failed to load recipe');
}
};
const handleStartRecipeChatInNewWindow = (recipeId: string) => {
window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR') as string,
undefined,
undefined,
'pair',
recipeId
);
};
const handleDeleteRecipe = async (recipeManifest: RecipeManifest) => {
const result = await window.electron.showMessageBox({
type: 'warning',
@@ -368,6 +363,18 @@ export default function RecipesView() {
>
<Play className="w-4 h-4" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
handleStartRecipeChatInNewWindow(recipeManifestResponse.id);
}}
variant="outline"
size="sm"
className="h-8 w-8 p-0"
title="Open in new window"
>
<ExternalLink className="w-4 h-4" />
</Button>
<Button
onClick={(e) => {
e.stopPropagation();
@@ -33,7 +33,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { Message, Session } from '../../api';
import { useNavigation } from '../../hooks/useNavigation';
// Helper function to determine if a message is a user message (same as useChatEngine)
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
@@ -111,10 +110,6 @@ const SessionMessages: React.FC<{
}}
toolCallNotifications={new Map()}
append={() => {}} // Read-only for session history
appendMessage={(newMessage) => {
// Read-only - do nothing
console.log('appendMessage called in read-only session history:', newMessage);
}}
isUserMessage={isUserMessage} // Use the same function as BaseChat
batchSize={15} // Same as BaseChat default
batchDelay={30} // Same as BaseChat default
@@ -9,6 +9,7 @@ import {
Trash2,
Download,
Upload,
ExternalLink,
} from 'lucide-react';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
@@ -488,16 +489,29 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
[loadSessions]
);
const handleOpenInNewWindow = useCallback((session: Session, e: React.MouseEvent) => {
e.stopPropagation();
window.electron.createChatWindow(
undefined,
session.working_dir,
undefined,
session.id,
'pair'
);
}, []);
const SessionItem = React.memo(function SessionItem({
session,
onEditClick,
onDeleteClick,
onExportClick,
onOpenInNewWindow,
}: {
session: Session;
onEditClick: (session: Session) => void;
onDeleteClick: (session: Session) => void;
onExportClick: (session: Session, e: React.MouseEvent) => void;
onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void;
}) {
const handleEditClick = useCallback(
(e: React.MouseEvent) => {
@@ -526,6 +540,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
[onExportClick, session]
);
const handleOpenInNewWindowClick = useCallback(
(e: React.MouseEvent) => {
onOpenInNewWindow(session, e);
},
[onOpenInNewWindow, session]
);
return (
<Card
onClick={handleCardClick}
@@ -535,6 +556,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex items-start justify-between gap-2 mb-1">
<h3 className="text-base break-words line-clamp-2 flex-1 min-w-0">{session.name}</h3>
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0">
<button
onClick={handleOpenInNewWindowClick}
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
title="Open in new window"
>
<ExternalLink className="w-3 h-3 text-textSubtle hover:text-textStandard" />
</button>
<button
onClick={handleEditClick}
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
@@ -674,6 +702,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
onEditClick={handleEditSession}
onDeleteClick={handleDeleteSession}
onExportClick={handleExportSession}
onOpenInNewWindow={handleOpenInNewWindow}
/>
))}
</div>
@@ -38,14 +38,10 @@ const SessionsView: React.FC = () => {
const handleSelectSession = useCallback(
async (sessionId: string) => {
if (process.env.ALPHA) {
setView('pair', {
disableAnimation: true,
resumeSessionId: sessionId,
});
} else {
await loadSessionDetails(sessionId);
}
setView('pair', {
disableAnimation: true,
resumeSessionId: sessionId,
});
},
[setView]
);
-167
View File
@@ -1,167 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { act, renderHook } from '@testing-library/react';
import type { Mock } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useChatEngine } from './useChatEngine';
import { getTextContent } from '../types/message';
import { Message } from '../api';
import { ChatType } from '../types/chat';
// Mock the useMessageStream hook which is a dependency of useChatEngine
vi.mock('./useMessageStream', () => ({
useMessageStream: vi.fn(),
}));
// Mock the sessions API which is another dependency
vi.mock('../sessions', () => ({
fetchSessionDetails: vi.fn().mockResolvedValue({ metadata: {} }),
}));
describe('useChatEngine', () => {
let mockUseMessageStream: Mock;
beforeEach(async () => {
// Mock the appConfig and electron APIs on the existing window object
Object.defineProperty(window, 'appConfig', {
value: {
get: vi.fn((key: string) => {
if (key === 'GOOSE_API_HOST') return 'http://localhost';
if (key === 'GOOSE_PORT') return '8000';
return null;
}),
},
writable: true,
});
Object.defineProperty(window, 'electron', {
value: {
logInfo: vi.fn(),
},
writable: true,
});
// Dynamically import the hook so we can get a reference to the mock
const { useMessageStream } = await import('./useMessageStream');
mockUseMessageStream = useMessageStream as Mock;
// Reset all mocks before each test to ensure a clean state
vi.clearAllMocks();
// Provide a complete, default mock implementation for useMessageStream
mockUseMessageStream.mockReturnValue({
messages: [],
append: vi.fn(),
stop: vi.fn(),
chatState: 'idle',
error: undefined,
setMessages: vi.fn(),
input: '',
setInput: vi.fn(),
handleInputChange: vi.fn(),
handleSubmit: vi.fn(),
updateMessageStreamBody: vi.fn(),
notifications: [],
sessionMetadata: undefined,
setError: vi.fn(),
});
});
describe('onMessageUpdate', () => {
it('should truncate history and append the updated message when a message is edited', () => {
// --- 1. ARRANGE ---
const metadata = {
agentVisible: true,
userVisible: true,
};
const initialMessages: Message[] = [
{
id: '1',
role: 'user',
content: [{ type: 'text', text: 'First message' }],
created: 0,
metadata,
},
{
id: '2',
role: 'assistant',
content: [{ type: 'text', text: 'First response' }],
created: 1,
metadata,
},
{
id: '3',
role: 'user',
content: [{ type: 'text', text: 'Message to be edited' }],
created: 2,
metadata,
},
{
id: '4',
role: 'assistant',
content: [{ type: 'text', text: 'Response to be deleted' }],
created: 3,
metadata,
},
];
const mockSetMessages = vi.fn();
const mockAppend = vi.fn();
// Configure the mock to return specific values for this test case
mockUseMessageStream.mockReturnValue({
messages: initialMessages,
append: mockAppend,
setMessages: mockSetMessages,
notifications: [],
stop: vi.fn(),
chatState: 'idle',
error: undefined,
input: '',
setInput: vi.fn(),
handleInputChange: vi.fn(),
handleSubmit: vi.fn(),
updateMessageStreamBody: vi.fn(),
sessionMetadata: undefined,
setError: vi.fn(),
});
const mockChat: ChatType = {
sessionId: 'test-chat',
messages: initialMessages,
name: 'Test Chat',
messageHistoryIndex: 0,
};
// Render the hook with our test setup
const { result } = renderHook(() =>
useChatEngine({
chat: mockChat,
setChat: vi.fn(),
})
);
const messageIdToUpdate = '3';
const newContent = 'This is the edited message.';
// --- 2. ACT ---
// Call the function we want to test
act(() => {
result.current.onMessageUpdate(messageIdToUpdate, newContent);
});
// --- 3. ASSERT ---
// Verify that setMessages was called with the correctly truncated history
const expectedTruncatedHistory = initialMessages.slice(0, 2);
expect(mockSetMessages).toHaveBeenCalledWith(expectedTruncatedHistory);
// Verify that append was called with the new message
expect(mockAppend).toHaveBeenCalledTimes(1);
const appendedMessage = mockAppend.mock.calls[0][0];
expect(getTextContent(appendedMessage)).toBe(newContent);
expect(appendedMessage.role).toBe('user');
});
});
});
-473
View File
@@ -1,473 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getApiUrl } from '../config';
import { useMessageStream } from './useMessageStream';
import { LocalMessageStorage } from '../utils/localMessageStorage';
import { createUserMessage, getTextContent, ToolResponseMessageContent } from '../types/message';
import { getSession, Message } from '../api';
import { ChatType } from '../types/chat';
import { ChatState } from '../types/chatState';
// Helper function to determine if a message is a user message
const isUserMessage = (message: Message): boolean => {
if (message.role === 'assistant') {
return false;
}
return !message.content.every((c) => c.type === 'toolConfirmationRequest');
};
interface UseChatEngineProps {
chat: ChatType;
setChat: (chat: ChatType) => void;
onMessageStreamFinish?: () => void;
onMessageSent?: () => void; // Add callback for when message is sent
}
export const useChatEngine = ({
chat,
setChat,
onMessageStreamFinish,
onMessageSent,
}: UseChatEngineProps) => {
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
const [sessionTokenCount, setSessionTokenCount] = useState<number>(0);
const [sessionInputTokens, setSessionInputTokens] = useState<number>(0);
const [sessionOutputTokens, setSessionOutputTokens] = useState<number>(0);
const [localInputTokens, setLocalInputTokens] = useState<number>(0);
const [localOutputTokens, setLocalOutputTokens] = useState<number>(0);
const [powerSaveTimeoutId, setPowerSaveTimeoutId] = useState<number | null>(null);
// Track pending edited message
const [pendingEdit, setPendingEdit] = useState<{ id: string; content: string } | null>(null);
// Store message in global history when it's added
const storeMessageInHistory = useCallback((message: Message) => {
if (isUserMessage(message)) {
const text = getTextContent(message);
if (text) {
LocalMessageStorage.addMessage(text);
}
}
}, []);
const stopPowerSaveBlocker = useCallback(() => {
try {
window.electron.stopPowerSaveBlocker();
} catch (error) {
console.error('Failed to stop power save blocker:', error);
}
// Clear timeout if it exists
if (powerSaveTimeoutId) {
window.clearTimeout(powerSaveTimeoutId);
setPowerSaveTimeoutId(null);
}
}, [powerSaveTimeoutId]);
const {
messages,
append: originalAppend,
stop,
chatState,
error,
setMessages,
input: _input,
setInput: _setInput,
handleInputChange: _handleInputChange,
updateMessageStreamBody,
notifications,
session,
setError,
tokenState,
} = useMessageStream({
api: getApiUrl('/reply'),
id: chat.sessionId,
initialMessages: chat.messages,
body: {
session_id: chat.sessionId,
session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR'),
...(chat.recipe?.title
? {
recipe_name: chat.recipe.title,
recipe_version: chat.recipe?.version ?? 'unknown',
}
: {}),
},
onFinish: async (_message, _reason) => {
stopPowerSaveBlocker();
const timeSinceLastInteraction = Date.now() - lastInteractionTime;
window.electron.logInfo('last interaction:' + lastInteractionTime);
if (timeSinceLastInteraction > 60000) {
// 60000ms = 1 minute
window.electron.showNotification({
title: 'Goose finished the task.',
body: 'Click here to expand.',
});
}
// Always emit refresh event when message stream finishes for new sessions
// Check if this is a new session by looking at the current session ID format
const isNewSession = chat.sessionId && chat.sessionId.match(/^\d{8}_\d{6}$/);
if (isNewSession) {
console.log(
'ChatEngine: Message stream finished for new session, emitting message-stream-finished event'
);
// Emit event to trigger session refresh
window.dispatchEvent(new CustomEvent('message-stream-finished'));
}
onMessageStreamFinish?.();
},
onError: (error) => {
stopPowerSaveBlocker();
console.log(
'CHAT ENGINE RECEIVED ERROR FROM MESSAGE STREAM:',
JSON.stringify(
{
errorMessage: error.message,
errorName: error.name,
isTokenLimitError: (error as Error & { isTokenLimitError?: boolean }).isTokenLimitError,
errorStack: error.stack,
timestamp: new Date().toISOString(),
sessionId: chat.sessionId,
},
null,
2
)
);
},
});
// Wrap append to store messages in global history
const append = useCallback(
(messageOrString: Message | string) => {
const message =
typeof messageOrString === 'string' ? createUserMessage(messageOrString) : messageOrString;
storeMessageInHistory(message);
// If this is the first message in a new session, trigger a refresh immediately
// Only trigger if we're starting a completely new session (no existing messages)
if (messages.length === 0 && chat.messages.length === 0) {
// Emit event to indicate a new session is being created
window.dispatchEvent(new CustomEvent('session-created'));
}
return originalAppend(message);
},
[originalAppend, storeMessageInHistory, messages.length, chat.messages.length]
);
// Simple token estimation function (roughly 4 characters per token)
const estimateTokens = (text: string): number => {
return Math.ceil(text.length / 4);
};
// Calculate token counts from messages
useEffect(() => {
let inputTokens = 0;
let outputTokens = 0;
messages.forEach((message) => {
const textContent = getTextContent(message);
if (textContent) {
const tokens = estimateTokens(textContent);
if (message.role === 'user') {
inputTokens += tokens;
} else if (message.role === 'assistant') {
outputTokens += tokens;
}
}
});
setLocalInputTokens(inputTokens);
setLocalOutputTokens(outputTokens);
}, [messages]);
// Update chat messages when they change
useEffect(() => {
// @ts-expect-error - TypeScript being overly strict about the return type
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
}, [messages, setChat]);
useEffect(() => {
const fetchSessionTokens = async () => {
try {
const response = await getSession<true>({
path: { session_id: chat.sessionId },
throwOnError: true,
});
const sessionDetails = response.data;
setSessionTokenCount(sessionDetails.total_tokens || 0);
setSessionInputTokens(sessionDetails.accumulated_input_tokens || 0);
setSessionOutputTokens(sessionDetails.accumulated_output_tokens || 0);
} catch (err) {
console.error('Error fetching session token count:', err);
}
};
// Only fetch session tokens when chat state is idle to avoid resetting during streaming
if (chat.sessionId && chatState === ChatState.Idle) {
fetchSessionTokens();
}
}, [chat.sessionId, messages, chatState]);
// Update token counts when session changes from the message stream
useEffect(() => {
if (session) {
setSessionTokenCount(session.total_tokens || 0);
setSessionInputTokens(session.accumulated_input_tokens || 0);
setSessionOutputTokens(session.accumulated_output_tokens || 0);
}
}, [session]);
useEffect(() => {
return () => {
if (powerSaveTimeoutId) {
window.clearTimeout(powerSaveTimeoutId);
}
try {
window.electron.stopPowerSaveBlocker();
} catch (error) {
console.error('Failed to stop power save blocker during cleanup:', error);
}
};
}, [powerSaveTimeoutId]);
// Handle submit
const handleSubmit = useCallback(
(combinedTextFromInput: string, onSummaryReset?: () => void) => {
if (combinedTextFromInput.trim()) {
try {
window.electron.startPowerSaveBlocker();
} catch (error) {
console.error('Failed to start power save blocker:', error);
}
setLastInteractionTime(Date.now());
// Set a timeout to automatically stop the power save blocker after 15 minutes
const timeoutId = window.setTimeout(
() => {
console.warn('Power save blocker timeout - stopping automatically after 15 minutes');
stopPowerSaveBlocker();
},
15 * 60 * 1000
);
setPowerSaveTimeoutId(timeoutId);
const userMessage = createUserMessage(combinedTextFromInput.trim());
if (onSummaryReset) {
onSummaryReset();
window.setTimeout(() => {
append(userMessage);
onMessageSent?.();
}, 150);
} else {
append(userMessage);
onMessageSent?.();
}
} else {
// If nothing was actually submitted (e.g. empty input and no images pasted)
stopPowerSaveBlocker();
}
},
[append, onMessageSent, stopPowerSaveBlocker]
);
// Handle stopping the message stream
const onStopGoose = useCallback(() => {
stop();
setLastInteractionTime(Date.now());
stopPowerSaveBlocker();
// Handle stopping the message stream
const lastMessage = messages[messages.length - 1];
// Check if there are any messages before proceeding
if (!lastMessage) {
return;
}
// check if the last user message has any tool response(s)
const isToolResponse = lastMessage.content.some(
(content): content is ToolResponseMessageContent => content.type == 'toolResponse'
);
// isUserMessage also checks if the message is a toolConfirmationRequest
// check if the last message is a real user's message
if (lastMessage && isUserMessage(lastMessage) && !isToolResponse) {
const textValue = getTextContent(lastMessage);
_setInput(textValue);
// Also add to local storage history as a backup so cmd+up can retrieve it
if (textValue.trim()) {
LocalMessageStorage.addMessage(textValue.trim());
}
// Remove the last user message if it's the most recent one
if (messages.length > 1) {
setMessages(messages.slice(0, -1));
} else {
setMessages([]);
}
} else if (!isUserMessage(lastMessage)) {
const toolRequests: [string, Record<string, unknown>][] = lastMessage.content
.filter(
(content) => content.type === 'toolRequest' || content.type === 'toolConfirmationRequest'
)
.map((content) => {
if (content.type === 'toolRequest') {
return [content.id, content.toolCall];
} else {
const toolCall = {
status: 'success',
value: {
name: content.toolName,
arguments: content.arguments,
},
};
return [content.id, toolCall];
}
});
if (toolRequests.length !== 0) {
// This means we were interrupted during a tool request
// Create tool responses for all interrupted tool requests
let responseMessage: Message = {
role: 'user',
created: Date.now(),
content: [],
metadata: { userVisible: true, agentVisible: true },
};
const notification = 'Interrupted by the user to make a correction';
// generate a response saying it was interrupted for each tool request
for (const [reqId, _] of toolRequests) {
const toolResponse: ToolResponseMessageContent = {
type: 'toolResponse',
id: reqId,
toolResult: {
status: 'error',
error: notification,
},
};
responseMessage.content.push(toolResponse);
}
// Use an immutable update to add the response message to the messages array
setMessages([...messages, responseMessage]);
}
}
}, [stop, messages, _setInput, setMessages, stopPowerSaveBlocker]);
// Since server now handles all filtering, we just use messages directly
const filteredMessages = useMemo(() => {
return messages;
}, [messages]);
// Generate command history from messages
const commandHistory = useMemo(() => {
return filteredMessages
.reduce<string[]>((history, message) => {
if (isUserMessage(message)) {
const text = getTextContent(message).trim();
if (text) {
history.push(text);
}
}
return history;
}, [])
.reverse();
}, [filteredMessages]);
// Process tool call notifications
const toolCallNotifications = useMemo(() => {
return notifications.reduce((map, item) => {
const key = item.request_id;
if (!map.has(key)) {
map.set(key, []);
}
map.get(key).push(item);
return map;
}, new Map());
}, [notifications]);
// Handle message updates from the UI
const onMessageUpdate = useCallback(
(messageId: string, newContent: string) => {
const messageIndex = messages.findIndex((msg) => msg.id === messageId);
if (messageIndex !== -1) {
// Truncate the history to the point *before* the edited message.
const history = messages.slice(0, messageIndex);
// Set the truncated history.
setMessages(history);
// Instead of setTimeout, set pendingEdit which will be handled in useEffect
setPendingEdit({ id: messageId, content: newContent });
}
},
[messages, setMessages, setPendingEdit]
);
// Listen for pending edit and append message after messages updated
useEffect(() => {
if (pendingEdit) {
const updatedMessage = createUserMessage(pendingEdit.content);
append(updatedMessage);
setPendingEdit(null); // Reset after processing
}
}, [pendingEdit, append]);
return {
// Core message data
messages,
filteredMessages,
// Message stream controls
append,
stop,
chatState,
error,
setMessages,
// Input controls
input: _input,
setInput: _setInput,
handleInputChange: _handleInputChange,
// Event handlers
handleSubmit,
onStopGoose,
// Token and session data
sessionTokenCount,
sessionInputTokens,
sessionOutputTokens,
localInputTokens,
localOutputTokens,
tokenState,
// UI helpers
commandHistory,
toolCallNotifications,
// Stream utilities
updateMessageStreamBody,
sessionMetadata: session,
// Utilities
isUserMessage,
// Error management
clearError: () => setError(undefined),
// New functions for message editing
onMessageUpdate,
};
};
+170 -118
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChatState } from '../types/chatState';
import {
@@ -12,45 +12,20 @@ import {
updateSessionUserRecipeValues,
} from '../api';
import { createUserMessage, getCompactingMessage, getThinkingMessage } from '../types/message';
import {
createUserMessage,
getCompactingMessage,
getThinkingMessage,
NotificationEvent,
} from '../types/message';
import { errorMessage } from '../utils/conversionUtils';
const resultsCache = new Map<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);
},
};
interface UseChatStreamProps {
sessionId: string;
onStreamFinish: () => void;
initialMessage?: string;
onSessionLoaded?: () => void;
}
interface UseChatStreamReturn {
@@ -62,6 +37,12 @@ interface UseChatStreamReturn {
stopStreaming: () => void;
sessionLoadError?: string;
tokenState: TokenState;
notifications: Map<string, NotificationEvent[]>;
onMessageUpdate: (
messageId: string,
newContent: string,
editType?: 'fork' | 'edit'
) => Promise<void>;
}
function pushMessage(currentMessages: Message[], incomingMsg: Message): Message[] {
@@ -92,78 +73,67 @@ async function streamFromResponse(
updateMessages: (messages: Message[]) => void,
updateTokenState: (tokenState: TokenState) => void,
updateChatState: (state: ChatState) => void,
updateNotifications: (notification: NotificationEvent) => void,
onFinish: (error?: string) => void
): Promise<void> {
let messageEventCount = 0;
let currentMessages = initialMessages;
try {
log.stream('reading-events');
for await (const event of stream) {
switch (event.type) {
case 'Message': {
messageEventCount++;
const msg = event.message;
currentMessages = pushMessage(currentMessages, msg);
if (getCompactingMessage(msg)) {
log.state(ChatState.Compacting, { reason: 'compacting notification' });
const hasToolConfirmation = msg.content.some(
(content) => content.type === 'toolConfirmationRequest'
);
if (hasToolConfirmation) {
updateChatState(ChatState.WaitingForUserInput);
} else if (getCompactingMessage(msg)) {
updateChatState(ChatState.Compacting);
} else if (getThinkingMessage(msg)) {
log.state(ChatState.Thinking, { reason: 'thinking notification' });
updateChatState(ChatState.Thinking);
}
if (messageEventCount % 10 === 0) {
log.stream('message-chunk', {
eventCount: messageEventCount,
messageCount: currentMessages.length,
});
} else {
updateChatState(ChatState.Streaming);
}
updateTokenState(event.token_state);
updateMessages(currentMessages);
break;
}
case 'Error': {
log.error('stream event error', event.error);
onFinish('Stream error: ' + event.error);
return;
}
case 'Finish': {
log.stream('finish-event', { reason: event.reason });
onFinish();
return;
}
case 'ModelChange': {
log.stream('model-change', {
model: event.model,
mode: event.mode,
});
break;
}
case 'UpdateConversation': {
log.messages('conversation-update', event.conversation.length);
// WARNING: Since Message handler uses this local variable, we need to update it here to avoid the client clobbering it.
// Longterm fix is to only send the agent the new messages, not the entire conversation.
currentMessages = event.conversation;
updateMessages(event.conversation);
break;
}
case 'Notification':
case 'Notification': {
updateNotifications(event as NotificationEvent);
break;
}
case 'Ping':
break;
}
}
log.stream('events-complete', { messageEvents: messageEventCount });
onFinish();
} catch (error) {
if (error instanceof Error && error.name !== 'AbortError') {
log.error('stream read error', error);
onFinish('Stream error: ' + error);
onFinish('Stream error: ' + errorMessage(error));
}
}
}
@@ -171,7 +141,7 @@ async function streamFromResponse(
export function useChatStream({
sessionId,
onStreamFinish,
initialMessage,
onSessionLoaded,
}: UseChatStreamProps): UseChatStreamReturn {
const [messages, setMessages] = useState<Message[]>([]);
const messagesRef = useRef<Message[]>([]);
@@ -186,6 +156,7 @@ export function useChatStream({
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
});
const [notifications, setNotifications] = useState<NotificationEvent[]>([]);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
@@ -194,45 +165,55 @@ export function useChatStream({
}
}, [sessionId, session, messages]);
const renderCountRef = useRef(0);
renderCountRef.current += 1;
console.log(`useChatStream render #${renderCountRef.current}, ${session?.id}`);
const setMessagesAndLog = useCallback((newMessages: Message[], logContext: string) => {
log.messages(logContext, newMessages.length, {
lastMessageRole: newMessages[newMessages.length - 1]?.role,
lastMessageId: newMessages[newMessages.length - 1]?.id?.slice(0, 8),
});
const updateMessages = useCallback((newMessages: Message[]) => {
setMessages(newMessages);
messagesRef.current = newMessages;
}, []);
const updateNotifications = useCallback((notification: NotificationEvent) => {
setNotifications((prev) => [...prev, notification]);
}, []);
const onFinish = useCallback(
(error?: string): void => {
async (error?: string): Promise<void> => {
if (error) {
setSessionLoadError(error);
}
const isNewSession = sessionId && sessionId.match(/^\d{8}_\d{6}$/);
if (isNewSession) {
console.log(
'useChatStream: Message stream finished for new session, emitting message-stream-finished event'
);
window.dispatchEvent(new CustomEvent('message-stream-finished'));
}
setChatState(ChatState.Idle);
onStreamFinish();
},
[onStreamFinish]
[onStreamFinish, sessionId]
);
// Load session on mount or sessionId change
useEffect(() => {
if (!sessionId) return;
const cached = resultsCache.get(sessionId);
if (cached) {
setSession(cached.session);
updateMessages(cached.messages);
setChatState(ChatState.Idle);
return;
}
// Reset state when sessionId changes
log.session('loading', sessionId);
setMessagesAndLog([], 'session-reset');
updateMessages([]);
setSession(undefined);
setSessionLoadError(undefined);
setChatState(ChatState.LoadingConversation);
let cancelled = false;
log.state(ChatState.LoadingConversation, { reason: 'session load start' });
(async () => {
try {
const response = await resumeAgent({
@@ -242,26 +223,20 @@ export function useChatStream({
},
throwOnError: true,
});
if (cancelled) return;
if (cancelled) {
return;
}
const session = response.data;
log.session('loaded', sessionId, {
messageCount: session?.conversation?.length || 0,
name: session?.name,
});
setSession(session);
setMessagesAndLog(session?.conversation || [], 'load-session');
log.state(ChatState.Idle, { reason: 'session load complete' });
updateMessages(session?.conversation || []);
setChatState(ChatState.Idle);
onSessionLoaded?.();
} catch (error) {
if (cancelled) return;
log.error('session load failed', error);
setSessionLoadError(error instanceof Error ? error.message : String(error));
log.state(ChatState.Idle, { reason: 'session load error' });
setSessionLoadError(errorMessage(error));
setChatState(ChatState.Idle);
}
})();
@@ -269,25 +244,43 @@ export function useChatStream({
return () => {
cancelled = true;
};
}, [sessionId, setMessagesAndLog]);
}, [sessionId, updateMessages, onSessionLoaded]);
const handleSubmit = useCallback(
async (userMessage: string) => {
log.messages('user-submit', messagesRef.current.length + 1, {
userMessageLength: userMessage.length,
});
// Guard: Don't submit if session hasn't been loaded yet
if (!session || chatState === ChatState.LoadingConversation) {
return;
}
const currentMessages = [...messagesRef.current, createUserMessage(userMessage)];
setMessagesAndLog(currentMessages, 'user-entered');
const hasExistingMessages = messagesRef.current.length > 0;
const hasNewMessage = userMessage.trim().length > 0;
// Don't submit if there's no message and no conversation to continue
if (!hasNewMessage && !hasExistingMessages) {
return;
}
// Emit session-created event for first message in a new session
if (!hasExistingMessages && hasNewMessage) {
window.dispatchEvent(new CustomEvent('session-created'));
}
// Build message list: add new message if provided, otherwise continue with existing
const currentMessages = hasNewMessage
? [...messagesRef.current, createUserMessage(userMessage)]
: [...messagesRef.current];
// Update UI with new message before streaming
if (hasNewMessage) {
updateMessages(currentMessages);
}
log.state(ChatState.Streaming, { reason: 'user submit' });
setChatState(ChatState.Streaming);
setNotifications([]);
abortControllerRef.current = new AbortController();
try {
log.stream('request-start', { sessionId: sessionId.slice(0, 8) });
const { stream } = await reply({
body: {
session_id: sessionId,
@@ -297,30 +290,26 @@ export function useChatStream({
signal: abortControllerRef.current.signal,
});
log.stream('stream-started');
await streamFromResponse(
stream,
currentMessages,
(messages: Message[]) => setMessagesAndLog(messages, 'streaming'),
updateMessages,
setTokenState,
setChatState,
updateNotifications,
onFinish
);
log.stream('stream-complete');
} catch (error) {
// AbortError is expected when user stops streaming
if (error instanceof Error && error.name === 'AbortError') {
log.stream('stream-aborted');
// Silently handle abort
} else {
// Unexpected error during fetch setup (streamFromResponse handles its own errors)
log.error('submit failed', error);
onFinish('Submit error: ' + (error instanceof Error ? error.message : String(error)));
onFinish('Submit error: ' + errorMessage(error));
}
}
},
[sessionId, setMessagesAndLog, onFinish]
[sessionId, session, chatState, updateMessages, updateNotifications, onFinish]
);
const setRecipeUserParams = useCallback(
@@ -361,25 +350,86 @@ export function useChatStream({
}
}, [session]);
useEffect(() => {
if (initialMessage && session && messages.length === 0 && chatState === ChatState.Idle) {
log.messages('auto-submit-initial', 0, { initialMessage: initialMessage.slice(0, 50) });
handleSubmit(initialMessage);
}
}, [initialMessage, session, messages.length, chatState, handleSubmit]);
const stopStreaming = useCallback(() => {
log.stream('stop-requested');
abortControllerRef.current?.abort();
log.state(ChatState.Idle, { reason: 'user stopped streaming' });
setChatState(ChatState.Idle);
}, []);
const onMessageUpdate = useCallback(
async (messageId: string, newContent: string, editType: 'fork' | 'edit' = 'fork') => {
try {
const { editMessage } = await import('../api');
const message = messagesRef.current.find((m) => m.id === messageId);
if (!message) {
throw new Error(`Message with id ${messageId} not found in current messages`);
}
const response = await editMessage({
path: {
session_id: sessionId,
},
body: {
timestamp: message.created,
editType,
},
throwOnError: true,
});
const targetSessionId = response.data?.sessionId;
if (!targetSessionId) {
throw new Error('No session ID returned from edit_message');
}
if (editType === 'fork') {
const event = new CustomEvent('session-forked', {
detail: {
newSessionId: targetSessionId,
shouldStartAgent: true,
editedMessage: newContent,
},
});
window.dispatchEvent(event);
window.electron.logInfo(`Dispatched session-forked event for session ${targetSessionId}`);
} else {
const { getSession } = await import('../api');
const sessionResponse = await getSession({
path: { session_id: targetSessionId },
throwOnError: true,
});
if (sessionResponse.data?.conversation) {
updateMessages(sessionResponse.data.conversation);
}
await handleSubmit(newContent);
}
} catch (error) {
const errorMsg = errorMessage(error);
console.error('Failed to edit message:', error);
const { toastError } = await import('../toasts');
toastError({
title: 'Failed to edit message',
msg: errorMsg,
});
}
},
[sessionId, handleSubmit, updateMessages]
);
const cached = resultsCache.get(sessionId);
const maybe_cached_messages = session ? messages : cached?.messages || [];
const maybe_cached_session = session ?? cached?.session;
console.log('>> returning', sessionId, Date.now(), maybe_cached_messages, chatState);
const notificationsMap = useMemo(() => {
return notifications.reduce((map, notification) => {
const key = notification.request_id;
if (!map.has(key)) {
map.set(key, []);
}
map.get(key)!.push(notification);
return map;
}, new Map<string, NotificationEvent[]>());
}, [notifications]);
return {
sessionLoadError,
@@ -390,5 +440,7 @@ export function useChatStream({
stopStreaming,
setRecipeUserParams,
tokenState,
notifications: notificationsMap,
onMessageUpdate,
};
}
-672
View File
@@ -1,672 +0,0 @@
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
import useSWR from 'swr';
import {
createUserMessage,
getThinkingMessage,
getCompactingMessage,
hasCompletedToolCalls,
} from '../types/message';
import { Conversation, Message, Role, TokenState } from '../api';
import { getSession, Session } from '../api';
import { ChatState } from '../types/chatState';
let messageIdCounter = 0;
function generateMessageId(): string {
return `msg-${Date.now()}-${++messageIdCounter}`;
}
// Ensure TextDecoder is available in the global scope
const TextDecoder = globalThis.TextDecoder;
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
export interface NotificationEvent {
type: 'Notification';
request_id: string;
message: {
method: string;
params: {
[key: string]: JsonValue;
};
};
}
// Event types for SSE stream
type MessageEvent =
| { type: 'Message'; message: Message; token_state: TokenState }
| { type: 'Error'; error: string }
| { type: 'Finish'; reason: string; token_state: TokenState }
| { type: 'ModelChange'; model: string; mode: string }
| { type: 'UpdateConversation'; conversation: Conversation }
| NotificationEvent;
export interface UseMessageStreamOptions {
/**
* The API endpoint that accepts a `{ messages: Message[] }` object and returns
* a stream of messages. Defaults to `/api/chat/reply`.
*/
api?: string;
/**
* A unique identifier for the chat. If not provided, a random one will be
* generated. When provided, the hook with the same `id` will
* have shared states across components.
*/
id?: string;
/**
* Initial messages of the chat. Useful to load an existing chat history.
*/
initialMessages?: Message[];
/**
* Initial input of the chat.
*/
initialInput?: string;
/**
* Callback function to be called when a tool call is received.
* You can optionally return a result for the tool call.
*/
_onToolCall?: (toolCall: Record<string, unknown>) => void | Promise<unknown> | unknown;
/**
* Callback function to be called when the API response is received.
*/
onResponse?: (response: Response) => void | Promise<void>;
/**
* Callback function to be called when the assistant message is finished streaming.
*/
onFinish?: (message: Message, reason: string) => void;
/**
* Callback function to be called when an error is encountered.
*/
onError?: (error: Error) => void;
/**
* HTTP headers to be sent with the API request.
*/
headers?: Record<string, string> | HeadersInit;
/**
* Extra body object to be sent with the API request.
*/
body?: object;
/**
* Maximum number of sequential LLM calls (steps), e.g. when you use tool calls.
* Default is 1.
*/
maxSteps?: number;
}
export interface UseMessageStreamHelpers {
/** Current messages in the chat */
messages: Message[];
/** The error object of the API request */
error: undefined | Error;
/**
* Append a user message to the chat list. This triggers the API call to fetch
* the assistant's response.
*/
append: (message: Message | string) => Promise<void>;
/**
* Reload the last AI chat response for the given chat history.
*/
reload: () => Promise<void>;
/**
* Abort the current request immediately.
*/
stop: () => void;
/**
* Update the `messages` state locally.
*/
setMessages: (messages: Message[] | ((messages: Message[]) => Message[])) => void;
/** The current value of the input */
input: string;
/** setState-powered method to update the input value */
setInput: React.Dispatch<React.SetStateAction<string>>;
/** An input/textarea-ready onChange handler to control the value of the input */
handleInputChange: (
e: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLTextAreaElement>
) => void;
/** Form submission handler to automatically reset input and append a user message */
handleSubmit: (event?: { preventDefault?: () => void }) => void;
/** Current chat state (idle, thinking, streaming, waiting for user input) */
chatState: ChatState;
/** Add a tool result to a tool call */
addToolResult: ({ toolCallId, result }: { toolCallId: string; result: unknown }) => void;
/** Modify body (session id and/or work dir mid-stream) **/
updateMessageStreamBody?: (newBody: object) => void;
notifications: NotificationEvent[];
/** Current model info from the backend */
currentModelInfo: { model: string; mode: string } | null;
/** Session including token counts */
session: Session | null;
/** Clear error state */
setError: (error: Error | undefined) => void;
/** Real-time token state from server */
tokenState: TokenState;
}
/**
* Hook for streaming messages directly from the server using the native Goose message format
*/
export function useMessageStream({
api = '/api/chat/reply',
id,
initialMessages = [],
initialInput = '',
onResponse,
onFinish,
onError,
headers,
body,
maxSteps = 1,
}: UseMessageStreamOptions = {}): UseMessageStreamHelpers {
// Generate a unique id for the chat if not provided
const hookId = useId();
const idKey = id ?? hookId;
const chatKey = typeof api === 'string' ? [api, idKey] : idKey;
// Store the chat state in SWR, using the chatId as the key to share states
const { data: messages, mutate } = useSWR<Message[]>([chatKey, 'messages'], null, {
fallbackData: initialMessages,
});
const [notifications, setNotifications] = useState<NotificationEvent[]>([]);
const [currentModelInfo, setCurrentModelInfo] = useState<{ model: string; mode: string } | null>(
null
);
const [session, setSession] = useState<Session | null>(null);
const [tokenState, setTokenState] = useState<TokenState>({
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
accumulatedInputTokens: 0,
accumulatedOutputTokens: 0,
accumulatedTotalTokens: 0,
});
// expose a way to update the body so we can update the session id when CLE occurs
const updateMessageStreamBody = useCallback((newBody: object) => {
extraMetadataRef.current.body = {
...extraMetadataRef.current.body,
...newBody,
};
}, []);
// Keep the latest messages in a ref
const messagesRef = useRef<Message[]>(messages || []);
useEffect(() => {
messagesRef.current = messages || [];
}, [messages]);
// Track chat state (idle, thinking, streaming, waiting for user input)
const { data: chatState = ChatState.Idle, mutate: mutateChatState } = useSWR<ChatState>(
[chatKey, 'chatState'],
null
);
const { data: error = undefined, mutate: setError } = useSWR<undefined | Error>(
[chatKey, 'error'],
null
);
// Abort controller to cancel the current API call
const abortControllerRef = useRef<AbortController | null>(null);
// Extra metadata for requests
const extraMetadataRef = useRef({
headers,
body,
});
useEffect(() => {
extraMetadataRef.current = {
headers,
body,
};
}, [headers, body]);
// TODO: not this?
const [, forceUpdate] = useReducer((x) => x + 1, 0);
// Process the SSE stream from the server
const processMessageStream = useCallback(
async (response: Response, currentMessages: Message[]) => {
if (!response.body) {
throw new Error('Response body is empty');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
try {
let running = true;
while (running) {
const { done, value } = await reader.read();
if (done) {
running = false;
break;
}
// Decode the chunk and add it to our buffer
buffer += decoder.decode(value, { stream: true });
// Process complete SSE events
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep the last incomplete event in the buffer
for (const event of events) {
if (event.startsWith('data: ')) {
try {
const data = event.slice(6); // Remove 'data: ' prefix
const parsedEvent = JSON.parse(data) as MessageEvent;
switch (parsedEvent.type) {
case 'Message': {
// Transition from waiting to streaming on first message
mutateChatState(ChatState.Streaming);
setTokenState(parsedEvent.token_state);
// Create a new message object with the properties preserved or defaulted
const newMessage: Message = {
...parsedEvent.message,
id: parsedEvent.message.id || undefined,
role: parsedEvent.message.role as Role,
created: parsedEvent.message.created || Date.now(),
content: parsedEvent.message.content || [],
};
// Update messages with the new message
if (
newMessage.id &&
currentMessages.length > 0 &&
currentMessages[currentMessages.length - 1].id === newMessage.id
) {
// If the last message has the same ID, update it instead of adding a new one
const lastMessage = currentMessages[currentMessages.length - 1];
lastMessage.content = [...lastMessage.content, ...newMessage.content];
forceUpdate();
} else {
currentMessages = [...currentMessages, newMessage];
}
// Check if this message contains tool confirmation requests
const hasToolConfirmation = newMessage.content.some(
(content) => content.type === 'toolConfirmationRequest'
);
if (hasToolConfirmation) {
mutateChatState(ChatState.WaitingForUserInput);
}
if (getCompactingMessage(newMessage)) {
mutateChatState(ChatState.Compacting);
} else if (getThinkingMessage(newMessage)) {
mutateChatState(ChatState.Thinking);
}
mutate(currentMessages, false);
break;
}
case 'Notification': {
const newNotification = {
...parsedEvent,
};
setNotifications((prev) => [...prev, newNotification]);
break;
}
case 'ModelChange': {
// Update the current model in the frontend
const modelInfo = {
model: parsedEvent.model,
mode: parsedEvent.mode,
};
setCurrentModelInfo(modelInfo);
break;
}
case 'UpdateConversation': {
// WARNING: Since Message handler uses this local variable, we need to update it here to avoid the client clobbering it.
// Longterm fix is to only send the agent the new messages, not the entire conversation.
currentMessages = parsedEvent.conversation;
setMessages(parsedEvent.conversation);
break;
}
case 'Error': {
// Always throw the error so it gets caught and sets the error state
// This ensures the retry UI appears for ALL errors
throw new Error(parsedEvent.error);
}
case 'Finish': {
setTokenState(parsedEvent.token_state);
if (onFinish && currentMessages.length > 0) {
const lastMessage = currentMessages[currentMessages.length - 1];
onFinish(lastMessage, parsedEvent.reason);
}
const sessionId = (extraMetadataRef.current.body as Record<string, unknown>)
?.session_id as string;
if (sessionId) {
const sessionResponse = await getSession({
path: { session_id: sessionId },
throwOnError: true,
});
if (sessionResponse.data) {
setSession(sessionResponse.data);
}
}
break;
}
}
} catch (e) {
console.error('Error parsing SSE event:', e);
if (onError && e instanceof Error) {
onError(e);
}
// Don't re-throw here, let the error be handled by the outer catch
// Instead, set the error state directly
if (e instanceof Error) {
setError(e);
}
}
}
}
}
} catch (e) {
if (e instanceof Error && e.name !== 'AbortError') {
console.error('Error reading SSE stream:', e);
if (onError) {
onError(e);
}
// Re-throw the error so it gets caught by sendRequest and sets the error state
throw e;
}
} finally {
reader.releaseLock();
}
return currentMessages;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[mutate, mutateChatState, onFinish, onError, forceUpdate, setError]
);
// Send a request to the server
const sendRequest = useCallback(
async (requestMessages: Message[]) => {
try {
mutateChatState(ChatState.Thinking); // Start in thinking state
setError(undefined);
// Create abort controller
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Send request to the server
const response = await fetch(api, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': await window.electron.getSecretKey(),
...extraMetadataRef.current.headers,
},
body: JSON.stringify({
messages: requestMessages,
...extraMetadataRef.current.body,
}),
signal: abortController.signal,
});
if (onResponse) {
await onResponse(response);
}
if (!response.ok) {
const text = await response.text();
throw new Error(text || `Error ${response.status}: ${response.statusText}`);
}
// Process the SSE stream
const updatedMessages = await processMessageStream(response, requestMessages);
// Auto-submit when all tool calls in the last assistant message have results
if (maxSteps > 1 && updatedMessages.length > requestMessages.length) {
const lastMessage = updatedMessages[updatedMessages.length - 1];
if (lastMessage.role === 'assistant' && hasCompletedToolCalls(lastMessage)) {
// Count trailing assistant messages to prevent infinite loops
let assistantCount = 0;
for (let i = updatedMessages.length - 1; i >= 0; i--) {
if (updatedMessages[i].role === 'assistant') {
assistantCount++;
} else {
break;
}
}
if (assistantCount < maxSteps) {
await sendRequest(updatedMessages);
}
}
}
abortControllerRef.current = null;
} catch (err) {
// Ignore abort errors as they are expected
if (err instanceof Error && err.name === 'AbortError') {
abortControllerRef.current = null;
return;
}
if (onError && err instanceof Error) {
onError(err);
}
setError(err as Error);
} finally {
// Check if the last message has pending tool confirmations
const currentMessages = messagesRef.current;
const lastMessage = currentMessages[currentMessages.length - 1];
const hasPendingToolConfirmation = lastMessage?.content.some(
(content) => content.type === 'toolConfirmationRequest'
);
if (hasPendingToolConfirmation) {
mutateChatState(ChatState.WaitingForUserInput);
} else {
mutateChatState(ChatState.Idle);
}
}
},
[api, processMessageStream, mutateChatState, setError, onResponse, onError, maxSteps]
);
// Append a new message and send request
const append = useCallback(
async (message: Message | string) => {
// If a string is passed, convert it to a Message object
const messageToAppend = typeof message === 'string' ? createUserMessage(message) : message;
// If we were waiting for user input and user provides input, transition away from that state
if (chatState === ChatState.WaitingForUserInput) {
mutateChatState(ChatState.Thinking);
}
const currentMessages = [...messagesRef.current, messageToAppend];
mutate(currentMessages, false);
await sendRequest(currentMessages);
},
[mutate, sendRequest, chatState, mutateChatState]
);
// Reload the last message
const reload = useCallback(async () => {
const currentMessages = messagesRef.current;
if (currentMessages.length === 0) {
return;
}
// Remove last assistant message if present
const lastMessage = currentMessages[currentMessages.length - 1];
const messagesToSend =
lastMessage.role === 'assistant' ? currentMessages.slice(0, -1) : currentMessages;
await sendRequest(messagesToSend);
}, [sendRequest]);
// Stop the current request
const stop = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
}, []);
// Set messages directly
const setMessages = useCallback(
(messagesOrFn: Message[] | ((messages: Message[]) => Message[])) => {
if (typeof messagesOrFn === 'function') {
const newMessages = messagesOrFn(messagesRef.current);
mutate(newMessages, false);
messagesRef.current = newMessages;
} else {
mutate(messagesOrFn, false);
messagesRef.current = messagesOrFn;
}
},
[mutate]
);
// Input state and handlers
const [input, setInput] = useState(initialInput);
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement> | React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
},
[]
);
const handleSubmit = useCallback(
async (event?: { preventDefault?: () => void }) => {
event?.preventDefault?.();
if (!input.trim()) return;
await append(input);
setInput('');
},
[input, append]
);
// Add tool result to a message
const addToolResult = useCallback(
({ toolCallId, result }: { toolCallId: string; result: unknown }) => {
const currentMessages = messagesRef.current;
// Find the last assistant message with the tool call
let lastAssistantIndex = -1;
for (let i = currentMessages.length - 1; i >= 0; i--) {
if (currentMessages[i].role === 'assistant') {
const toolRequests = currentMessages[i].content.filter(
(content) => content.type === 'toolRequest' && content.id === toolCallId
);
if (toolRequests.length > 0) {
lastAssistantIndex = i;
break;
}
}
}
if (lastAssistantIndex === -1) return;
// Create a tool response message
const toolResponseMessage: Message = {
id: generateMessageId(),
role: 'user' as const,
created: Math.floor(Date.now() / 1000),
metadata: { userVisible: true, agentVisible: true },
content: [
{
type: 'toolResponse' as const,
id: toolCallId,
toolResult: {
status: 'success' as const,
value: Array.isArray(result)
? result
: [{ type: 'text' as const, text: String(result), priority: 0 }],
},
},
],
};
// Insert the tool response after the assistant message
const updatedMessages = [
...currentMessages.slice(0, lastAssistantIndex + 1),
toolResponseMessage,
...currentMessages.slice(lastAssistantIndex + 1),
];
mutate(updatedMessages, false);
messagesRef.current = updatedMessages;
// Auto-submit if we have tool results
if (maxSteps > 1) {
sendRequest(updatedMessages);
}
},
[mutate, maxSteps, sendRequest]
);
return {
messages: messages || [],
error,
append,
reload,
stop,
setMessages,
input,
setInput,
handleInputChange,
handleSubmit,
chatState,
addToolResult,
updateMessageStreamBody,
notifications,
currentModelInfo,
session,
setError,
tokenState,
};
}
+41 -63
View File
@@ -47,7 +47,6 @@ import {
updateTrayMenu,
} from './utils/autoUpdater';
import { UPDATES_ENABLED } from './updates';
import { Recipe } from './recipe';
import './utils/recipeHash';
import { Client, createClient, createConfig } from './api/client';
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
@@ -192,9 +191,9 @@ if (process.platform !== 'darwin') {
undefined,
undefined,
undefined,
undefined,
recipeDeeplink || undefined,
scheduledJobId || undefined
scheduledJobId || undefined,
undefined
);
});
return; // Skip the rest of the handler
@@ -280,7 +279,7 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
} else if (parsedUrl.hostname === 'sessions') {
window.webContents.send('open-shared-session', pendingDeepLink);
} else if (parsedUrl.hostname === 'bot' || parsedUrl.hostname === 'recipe') {
const recipeDeeplink = parsedUrl.searchParams.get('config');
const recipeDeeplink = parseRecipeDeeplink(parsedUrl.toString());
const scheduledJobId = parsedUrl.searchParams.get('scheduledJob');
// Create a new window and ignore the passed-in window
@@ -291,12 +290,12 @@ async function processProtocolUrl(parsedUrl: URL, window: BrowserWindow) {
undefined,
undefined,
undefined,
undefined,
recipeDeeplink || undefined,
scheduledJobId || undefined
scheduledJobId || undefined,
undefined
);
pendingDeepLink = null;
}
pendingDeepLink = null;
}
let windowDeeplinkURL: string | null = null;
@@ -325,9 +324,9 @@ app.on('open-url', async (_event, url) => {
undefined,
undefined,
undefined,
undefined,
recipeDeeplink || undefined,
scheduledJobId || undefined
scheduledJobId || undefined,
undefined
);
windowDeeplinkURL = null;
return; // Skip the rest of the handler
@@ -350,7 +349,6 @@ app.on('open-url', async (_event, url) => {
} else if (parsedUrl.hostname === 'sessions') {
firstOpenWindow.webContents.send('open-shared-session', pendingDeepLink);
}
pendingDeepLink = null;
}
});
@@ -408,8 +406,8 @@ async function handleFileOpen(filePath: string) {
newWindow.focus();
newWindow.moveTop();
}
} catch {
console.error('Failed to handle file open');
} catch (error) {
console.error('Failed to handle file open:', error);
// Show user-friendly error notification
new Notification({
@@ -494,9 +492,8 @@ const createChat = async (
dir?: string,
_version?: string,
resumeSessionId?: string,
recipe?: Recipe, // Recipe configuration when already loaded, takes precedence over deeplink
viewType?: string,
recipeDeeplink?: string, // Raw deeplink used as a fallback when recipe is not loaded. Required on new windows as we need to wait for the window to load before decoding.
recipeDeeplink?: string, // Raw deeplink decoded on server
scheduledJobId?: string, // Scheduled job ID if applicable
recipeId?: string
) => {
@@ -693,10 +690,7 @@ const createChat = async (
}
if (
appPath === '/' &&
(recipe !== undefined ||
recipeDeeplink !== undefined ||
recipeId !== undefined ||
initialMessage)
(recipeDeeplink !== undefined || recipeId !== undefined || initialMessage)
) {
appPath = '/pair';
}
@@ -708,6 +702,14 @@ const createChat = async (
appPath = '/pair';
}
}
// Only add recipeId to URL for the non-deeplink case (saved recipes launched from UI)
// For deeplinks, the recipe object is passed via appConfig, not URL params
if (recipeId) {
searchParams.set('recipeId', recipeId);
if (appPath === '/') {
appPath = '/pair';
}
}
// Goose's react app uses HashRouter, so the path + search params follow a #/
url.hash = `${appPath}?${searchParams.toString()}`;
@@ -1041,15 +1043,14 @@ function parseRecipeDeeplink(url: string): string | undefined {
// URLSearchParams decodes + as space, which can break encoded configs
// Parse raw query to preserve "+" characters in values like config
const search = parsedUrl.search || '';
// parse recipe deeplink from search params
const configMatch = search.match(/(?:[?&])config=([^&]*)/);
// get recipe deeplink from config match
let recipeDeeplinkTmp = configMatch ? configMatch[1] : null;
if (recipeDeeplinkTmp) {
try {
recipeDeeplink = decodeURIComponent(recipeDeeplinkTmp);
} catch {
// Leave as-is if decoding fails
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error('[Main] parseRecipeDeeplink - Failed to decode:', errorMessage);
return undefined;
}
}
@@ -2054,22 +2055,18 @@ async function appMain() {
ipcMain.on(
'create-chat-window',
(_, query, dir, version, resumeSessionId, recipe, viewType, recipeId) => {
(_, query, dir, version, resumeSessionId, viewType, recipeId) => {
if (!dir?.trim()) {
const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : undefined;
}
// Log the recipe for debugging
console.log('Creating chat window with recipe:', recipe);
createChat(
app,
query,
dir,
version,
resumeSessionId,
recipe,
viewType,
undefined,
undefined,
@@ -2085,7 +2082,7 @@ async function appMain() {
}
});
ipcMain.on('notify', (_event, data) => {
ipcMain.on('notify', (event, data) => {
try {
// Validate notification data
if (!data || typeof data !== 'object') {
@@ -2110,10 +2107,24 @@ async function appMain() {
const sanitizeText = (text: string) => text.replace(/<[^>]*>/g, '');
console.log('NOTIFY', data);
new Notification({
const notification = new Notification({
title: sanitizeText(data.title),
body: sanitizeText(data.body),
}).show();
});
// Add click handler to focus the window
notification.on('click', () => {
const window = BrowserWindow.fromWebContents(event.sender);
if (window) {
if (window.isMinimized()) {
window.restore();
}
window.show();
window.focus();
}
});
notification.show();
} catch (error) {
console.error('Error showing notification:', error);
}
@@ -2163,39 +2174,6 @@ async function appMain() {
}
});
ipcMain.handle('start-power-save-blocker', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
const windowId = window?.id;
if (windowId && !windowPowerSaveBlockers.has(windowId)) {
const blockerId = powerSaveBlocker.start('prevent-app-suspension');
windowPowerSaveBlockers.set(windowId, blockerId);
console.log(`[Main] Started power save blocker ${blockerId} for window ${windowId}`);
return true;
}
if (windowId && windowPowerSaveBlockers.has(windowId)) {
console.log(`[Main] Power save blocker already active for window ${windowId}`);
}
return false;
});
ipcMain.handle('stop-power-save-blocker', (event) => {
const window = BrowserWindow.fromWebContents(event.sender);
const windowId = window?.id;
if (windowId && windowPowerSaveBlockers.has(windowId)) {
const blockerId = windowPowerSaveBlockers.get(windowId)!;
powerSaveBlocker.stop(blockerId);
windowPowerSaveBlockers.delete(windowId);
console.log(`[Main] Stopped power save blocker ${blockerId} for window ${windowId}`);
return true;
}
return false;
});
// Handle metadata fetching from main process
ipcMain.handle('fetch-metadata', async (_event, url) => {
try {
+2 -7
View File
@@ -52,7 +52,6 @@ type ElectronAPI = {
dir?: string,
version?: string,
resumeSessionId?: string,
recipe?: Recipe,
viewType?: string,
recipeId?: string
) => void;
@@ -64,8 +63,7 @@ type ElectronAPI = {
reloadApp: () => void;
checkForOllama: () => Promise<boolean>;
selectFileOrDirectory: (defaultPath?: string) => Promise<string | null>;
startPowerSaveBlocker: () => Promise<number>;
stopPowerSaveBlocker: () => Promise<void>;
getBinaryPath: (binaryName: string) => Promise<string>;
readFile: (directory: string) => Promise<FileResponse>;
writeFile: (directory: string, content: string) => Promise<boolean>;
ensureDirectory: (dirPath: string) => Promise<boolean>;
@@ -143,7 +141,6 @@ const electronAPI: ElectronAPI = {
dir?: string,
version?: string,
resumeSessionId?: string,
recipe?: Recipe,
viewType?: string,
recipeId?: string
) =>
@@ -153,7 +150,6 @@ const electronAPI: ElectronAPI = {
dir,
version,
resumeSessionId,
recipe,
viewType,
recipeId
),
@@ -166,8 +162,7 @@ const electronAPI: ElectronAPI = {
checkForOllama: () => ipcRenderer.invoke('check-ollama'),
selectFileOrDirectory: (defaultPath?: string) =>
ipcRenderer.invoke('select-file-or-directory', defaultPath),
startPowerSaveBlocker: () => ipcRenderer.invoke('start-power-save-blocker'),
stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'),
getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName),
readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
+42 -38
View File
@@ -2,48 +2,52 @@ import { Session, startAgent } from './api';
import type { setViewType } from './hooks/useNavigation';
export function resumeSession(session: Session, setView: setViewType) {
if (process.env.ALPHA) {
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
});
} else {
const workingDir = session.working_dir;
if (!workingDir) {
throw new Error('Cannot resume session: working directory is missing in session');
}
window.electron.createChatWindow(
undefined, // query
workingDir,
undefined, // version
session.id
);
setView('pair', {
disableAnimation: true,
resumeSessionId: session.id,
});
}
export async function createSession(options?: {
recipeId?: string;
recipeDeeplink?: string;
}): Promise<Session> {
const body: {
working_dir: string;
recipe_id?: string;
recipe_deeplink?: string;
} = {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
};
if (options?.recipeId) {
body.recipe_id = options.recipeId;
} else if (options?.recipeDeeplink) {
body.recipe_deeplink = options.recipeDeeplink;
}
const newAgent = await startAgent({
body,
throwOnError: true,
});
return newAgent.data;
}
export async function startNewSession(
initialText: string | undefined,
resetChat: (() => void) | null,
setView: setViewType
) {
if (!resetChat || process.env.ALPHA) {
const newAgent = await startAgent({
body: {
working_dir: window.appConfig.get('GOOSE_WORKING_DIR') as string,
},
throwOnError: true,
});
const session = newAgent.data;
setView('pair', {
disableAnimation: true,
initialMessage: initialText,
resumeSessionId: session.id,
});
} else {
resetChat();
setView('pair', {
disableAnimation: true,
initialMessage: initialText,
});
setView: setViewType,
options?: {
recipeId?: string;
recipeDeeplink?: string;
}
): Promise<Session> {
const session = await createSession(options);
setView('pair', {
disableAnimation: true,
initialMessage: initialText,
resumeSessionId: session.id,
});
return session;
}
+1 -1
View File
@@ -182,7 +182,7 @@ function ToastErrorContent({
</div>
<div className="flex-none flex items-center gap-2">
{showRecovery ? (
<Button onClick={() => startNewSession(recoverHints, null, setView)}>Ask goose</Button>
<Button onClick={() => startNewSession(recoverHints, setView)}>Ask goose</Button>
) : traceback ? (
<Button onClick={() => navigator.clipboard.writeText(traceback)}>Copy error</Button>
) : null}
+2 -20
View File
@@ -1,7 +1,8 @@
import { Message, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api';
import { Message, MessageEvent, ToolConfirmationRequest, ToolRequest, ToolResponse } from '../api';
export type ToolRequestMessageContent = ToolRequest & { type: 'toolRequest' };
export type ToolResponseMessageContent = ToolResponse & { type: 'toolResponse' };
export type NotificationEvent = Extract<MessageEvent, { type: 'Notification' }>;
// Compaction response message - must match backend constant
const COMPACTION_THINKING_TEXT = 'goose is compacting the conversation...';
@@ -16,25 +17,6 @@ export function createUserMessage(text: string): Message {
};
}
export function createToolErrorResponseMessage(id: string, error: string): Message {
return {
id: generateMessageId(),
role: 'user',
created: Math.floor(Date.now() / 1000),
content: [
{
type: 'toolResponse',
id,
toolResult: {
status: 'error',
error,
},
},
],
metadata: { userVisible: true, agentVisible: true },
};
}
export function generateMessageId(): string {
return Math.random().toString(36).substring(2, 10);
}