mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Next camp refactor live (#5706)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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]
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user