feat: store working directory for sessions (#1559)

This commit is contained in:
Salman Mohammed
2025-03-07 11:12:57 -05:00
committed by GitHub
parent 2d0cd8e245
commit 32f20cd690
20 changed files with 334 additions and 144 deletions

View File

@@ -35,7 +35,59 @@ pub async fn build_session(
let mut agent = AgentFactory::create(&AgentFactory::configured_version(), provider) let mut agent = AgentFactory::create(&AgentFactory::configured_version(), provider)
.expect("Failed to create agent"); .expect("Failed to create agent");
// Handle session file resolution and resuming
let session_file = if resume {
if let Some(identifier) = identifier {
let session_file = session::get_path(identifier);
if !session_file.exists() {
output::render_error(&format!(
"Cannot resume session {} - no such session exists",
style(session_file.display()).cyan()
));
process::exit(1);
}
session_file
} else {
// Try to resume most recent session
match session::get_most_recent_session() {
Ok(file) => file,
Err(_) => {
output::render_error("Cannot resume - no previous sessions found");
process::exit(1);
}
}
}
} else {
// Create new session with provided name/path or generated name
let id = match identifier {
Some(identifier) => identifier,
None => Identifier::Name(session::generate_session_id()),
};
// Just get the path - file will be created when needed
session::get_path(id)
};
if resume {
// Read the session metadata
let metadata = session::read_metadata(&session_file).unwrap_or_else(|e| {
output::render_error(&format!("Failed to read session metadata: {}", e));
process::exit(1);
});
// Ask user if they want to change the working directory
let change_workdir = cliclack::confirm(format!("{} The working directory of this session was set to {}. It does not match the current working directory. Would you like to change it?", style("WARNING:").yellow(), style(metadata.working_dir.display()).cyan()))
.initial_value(true)
.interact().expect("Failed to get user input");
if change_workdir {
std::env::set_current_dir(metadata.working_dir).unwrap();
}
}
// Setup extensions for the agent // Setup extensions for the agent
// Extensions need to be added after the session is created because we change directory when resuming a session
for extension in ExtensionManager::get_all().expect("should load extensions") { for extension in ExtensionManager::get_all().expect("should load extensions") {
if extension.enabled { if extension.enabled {
let config = extension.config.clone(); let config = extension.config.clone();
@@ -59,39 +111,6 @@ pub async fn build_session(
} }
} }
// Handle session file resolution and resuming
let session_file = if resume {
if let Some(identifier) = identifier {
let session_file = session::get_path(identifier);
if !session_file.exists() {
output::render_error(&format!(
"Cannot resume session {} - no such session exists",
style(session_file.display()).cyan()
));
process::exit(1);
}
session_file
} else {
// Try to resume most recent session
match session::get_most_recent_session() {
Ok(file) => file,
Err(_) => {
output::render_error("Cannot resume - no previous sessions found");
process::exit(1);
}
}
}
} else {
// Create new session with provided name/path or generated name
let id = match identifier {
Some(identifier) => identifier,
None => Identifier::Name(session::generate_session_id()),
};
// Just get the path - file will be created when needed
session::get_path(id)
};
// Create new session // Create new session
let mut session = Session::new(agent, session_file.clone(), debug); let mut session = Session::new(agent, session_file.clone(), debug);

View File

@@ -13,7 +13,7 @@ use completion::GooseCompleter;
use etcetera::choose_app_strategy; use etcetera::choose_app_strategy;
use etcetera::AppStrategy; use etcetera::AppStrategy;
use goose::agents::extension::{Envs, ExtensionConfig}; use goose::agents::extension::{Envs, ExtensionConfig};
use goose::agents::Agent; use goose::agents::{Agent, SessionConfig};
use goose::config::Config; use goose::config::Config;
use goose::message::{Message, MessageContent}; use goose::message::{Message, MessageContent};
use goose::session; use goose::session;
@@ -199,7 +199,6 @@ impl Session {
/// Process a single message and get the response /// Process a single message and get the response
async fn process_message(&mut self, message: String) -> Result<()> { async fn process_message(&mut self, message: String) -> Result<()> {
self.messages.push(Message::user().with_text(&message)); self.messages.push(Message::user().with_text(&message));
// Get the provider from the agent for description generation // Get the provider from the agent for description generation
let provider = self.agent.provider().await; let provider = self.agent.provider().await;
@@ -436,7 +435,17 @@ impl Session {
async fn process_agent_response(&mut self, interactive: bool) -> Result<()> { async fn process_agent_response(&mut self, interactive: bool) -> Result<()> {
let session_id = session::Identifier::Path(self.session_file.clone()); let session_id = session::Identifier::Path(self.session_file.clone());
let mut stream = self.agent.reply(&self.messages, Some(session_id)).await?; let mut stream = self
.agent
.reply(
&self.messages,
Some(SessionConfig {
id: session_id,
working_dir: std::env::current_dir()
.expect("failed to get current session working directory"),
}),
)
.await?;
use futures::StreamExt; use futures::StreamExt;
loop { loop {

View File

@@ -479,6 +479,13 @@ pub fn display_session_info(resume: bool, provider: &str, model: &str, session_f
style("logging to").dim(), style("logging to").dim(),
style(session_file.display()).dim().cyan(), style(session_file.display()).dim().cyan(),
); );
println!(
" {} {}",
style("working directory:").dim(),
style(std::env::current_dir().unwrap().display())
.cyan()
.dim()
);
} }
pub fn display_greeting() { pub fn display_greeting() {

View File

@@ -8,14 +8,18 @@ use axum::{
}; };
use bytes::Bytes; use bytes::Bytes;
use futures::{stream::StreamExt, Stream}; use futures::{stream::StreamExt, Stream};
use goose::message::{Message, MessageContent};
use goose::session; use goose::session;
use goose::{
agents::SessionConfig,
message::{Message, MessageContent},
};
use mcp_core::role::Role; use mcp_core::role::Role;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::{ use std::{
convert::Infallible, convert::Infallible,
path::PathBuf,
pin::Pin, pin::Pin,
task::{Context, Poll}, task::{Context, Poll},
time::Duration, time::Duration,
@@ -29,6 +33,7 @@ use tokio_stream::wrappers::ReceiverStream;
struct ChatRequest { struct ChatRequest {
messages: Vec<Message>, messages: Vec<Message>,
session_id: Option<String>, session_id: Option<String>,
session_working_dir: String,
} }
// Custom SSE response type for streaming messages // Custom SSE response type for streaming messages
@@ -108,8 +113,8 @@ async fn handler(
let (tx, rx) = mpsc::channel(100); let (tx, rx) = mpsc::channel(100);
let stream = ReceiverStream::new(rx); let stream = ReceiverStream::new(rx);
// Get messages directly from the request
let messages = request.messages; let messages = request.messages;
let session_working_dir = request.session_working_dir;
// Generate a new session ID if not provided in the request // Generate a new session ID if not provided in the request
let session_id = request let session_id = request
@@ -149,7 +154,10 @@ async fn handler(
let mut stream = match agent let mut stream = match agent
.reply( .reply(
&messages, &messages,
Some(session::Identifier::Name(session_id.clone())), Some(SessionConfig {
id: session::Identifier::Name(session_id.clone()),
working_dir: PathBuf::from(session_working_dir),
}),
) )
.await .await
{ {
@@ -246,6 +254,7 @@ async fn handler(
struct AskRequest { struct AskRequest {
prompt: String, prompt: String,
session_id: Option<String>, session_id: Option<String>,
session_working_dir: String,
} }
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
@@ -269,6 +278,8 @@ async fn ask_handler(
return Err(StatusCode::UNAUTHORIZED); return Err(StatusCode::UNAUTHORIZED);
} }
let session_working_dir = request.session_working_dir;
// Generate a new session ID if not provided in the request // Generate a new session ID if not provided in the request
let session_id = request let session_id = request
.session_id .session_id
@@ -289,7 +300,10 @@ async fn ask_handler(
let mut stream = match agent let mut stream = match agent
.reply( .reply(
&messages, &messages,
Some(session::Identifier::Name(session_id.clone())), Some(SessionConfig {
id: session::Identifier::Name(session_id.clone()),
working_dir: PathBuf::from(session_working_dir),
}),
) )
.await .await
{ {
@@ -464,6 +478,7 @@ mod tests {
serde_json::to_string(&AskRequest { serde_json::to_string(&AskRequest {
prompt: "test prompt".to_string(), prompt: "test prompt".to_string(),
session_id: Some("test-session".to_string()), session_id: Some("test-session".to_string()),
session_working_dir: "test-working-dir".to_string(),
}) })
.unwrap(), .unwrap(),
)) ))

View File

@@ -1,8 +1,10 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use futures::stream::BoxStream; use futures::stream::BoxStream;
use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use std::sync::Arc; use std::sync::Arc;
@@ -13,6 +15,15 @@ use crate::session;
use mcp_core::prompt::Prompt; use mcp_core::prompt::Prompt;
use mcp_core::protocol::GetPromptResult; use mcp_core::protocol::GetPromptResult;
/// Session configuration for an agent
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
/// Unique identifier for the session
pub id: session::Identifier,
/// Working directory for the session
pub working_dir: PathBuf,
}
/// Core trait defining the behavior of an Agent /// Core trait defining the behavior of an Agent
#[async_trait] #[async_trait]
pub trait Agent: Send + Sync { pub trait Agent: Send + Sync {
@@ -20,7 +31,7 @@ pub trait Agent: Send + Sync {
async fn reply( async fn reply(
&self, &self,
messages: &[Message], messages: &[Message],
session_id: Option<session::Identifier>, session: Option<SessionConfig>,
) -> Result<BoxStream<'_, Result<Message>>>; ) -> Result<BoxStream<'_, Result<Message>>>;
/// Add a new MCP client to the agent /// Add a new MCP client to the agent

View File

@@ -7,7 +7,7 @@ mod reference;
mod summarize; mod summarize;
mod truncate; mod truncate;
pub use agent::Agent; pub use agent::{Agent, SessionConfig};
pub use capabilities::Capabilities; pub use capabilities::Capabilities;
pub use extension::ExtensionConfig; pub use extension::ExtensionConfig;
pub use factory::{register_agent, AgentFactory}; pub use factory::{register_agent, AgentFactory};

View File

@@ -7,6 +7,7 @@ use std::sync::Arc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, instrument}; use tracing::{debug, instrument};
use super::agent::SessionConfig;
use super::Agent; use super::Agent;
use crate::agents::capabilities::Capabilities; use crate::agents::capabilities::Capabilities;
use crate::agents::extension::{ExtensionConfig, ExtensionResult}; use crate::agents::extension::{ExtensionConfig, ExtensionResult};
@@ -70,11 +71,11 @@ impl Agent for ReferenceAgent {
// TODO implement // TODO implement
} }
#[instrument(skip(self, messages), fields(user_message))] #[instrument(skip(self, messages, session), fields(user_message))]
async fn reply( async fn reply(
&self, &self,
messages: &[Message], messages: &[Message],
session_id: Option<session::Identifier>, session: Option<SessionConfig>,
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> { ) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
let mut messages = messages.to_vec(); let mut messages = messages.to_vec();
let reply_span = tracing::Span::current(); let reply_span = tracing::Span::current();
@@ -148,10 +149,11 @@ impl Agent for ReferenceAgent {
capabilities.record_usage(usage.clone()).await; capabilities.record_usage(usage.clone()).await;
// record usage for the session in the session file // record usage for the session in the session file
if let Some(session_id) = session_id.clone() { if let Some(session) = session.clone() {
// TODO: track session_id in langfuse tracing // TODO: track session_id in langfuse tracing
let session_file = session::get_path(session_id); let session_file = session::get_path(session.id);
let mut metadata = session::read_metadata(&session_file)?; let mut metadata = session::read_metadata(&session_file)?;
metadata.working_dir = session.working_dir;
metadata.total_tokens = usage.usage.total_tokens; metadata.total_tokens = usage.usage.total_tokens;
// The message count is the number of messages in the session + 1 for the response // The message count is the number of messages in the session + 1 for the response
// The message count does not include the tool response till next iteration // The message count does not include the tool response till next iteration

View File

@@ -9,6 +9,7 @@ use tokio::sync::mpsc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, error, instrument, warn}; use tracing::{debug, error, instrument, warn};
use super::agent::SessionConfig;
use super::detect_read_only_tools; use super::detect_read_only_tools;
use super::Agent; use super::Agent;
use crate::agents::capabilities::Capabilities; use crate::agents::capabilities::Capabilities;
@@ -162,11 +163,11 @@ impl Agent for SummarizeAgent {
} }
} }
#[instrument(skip(self, messages), fields(user_message))] #[instrument(skip(self, messages, session), fields(user_message))]
async fn reply( async fn reply(
&self, &self,
messages: &[Message], messages: &[Message],
session_id: Option<session::Identifier>, session: Option<SessionConfig>,
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> { ) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
let mut messages = messages.to_vec(); let mut messages = messages.to_vec();
let reply_span = tracing::Span::current(); let reply_span = tracing::Span::current();
@@ -246,10 +247,11 @@ impl Agent for SummarizeAgent {
capabilities.record_usage(usage.clone()).await; capabilities.record_usage(usage.clone()).await;
// record usage for the session in the session file // record usage for the session in the session file
if let Some(session_id) = session_id.clone() { if let Some(session) = session.clone() {
// TODO: track session_id in langfuse tracing // TODO: track session_id in langfuse tracing
let session_file = session::get_path(session_id); let session_file = session::get_path(session.id);
let mut metadata = session::read_metadata(&session_file)?; let mut metadata = session::read_metadata(&session_file)?;
metadata.working_dir = session.working_dir;
metadata.total_tokens = usage.usage.total_tokens; metadata.total_tokens = usage.usage.total_tokens;
// The message count is the number of messages in the session + 1 for the response // The message count is the number of messages in the session + 1 for the response
// The message count does not include the tool response till next iteration // The message count does not include the tool response till next iteration

View File

@@ -8,6 +8,7 @@ use tokio::sync::mpsc;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tracing::{debug, error, instrument, warn}; use tracing::{debug, error, instrument, warn};
use super::agent::SessionConfig;
use super::detect_read_only_tools; use super::detect_read_only_tools;
use super::Agent; use super::Agent;
use crate::agents::capabilities::Capabilities; use crate::agents::capabilities::Capabilities;
@@ -145,11 +146,11 @@ impl Agent for TruncateAgent {
} }
} }
#[instrument(skip(self, messages, session_id), fields(user_message))] #[instrument(skip(self, messages, session), fields(user_message))]
async fn reply( async fn reply(
&self, &self,
messages: &[Message], messages: &[Message],
session_id: Option<session::Identifier>, session: Option<SessionConfig>,
) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> { ) -> anyhow::Result<BoxStream<'_, anyhow::Result<Message>>> {
let mut messages = messages.to_vec(); let mut messages = messages.to_vec();
let reply_span = tracing::Span::current(); let reply_span = tracing::Span::current();
@@ -229,10 +230,11 @@ impl Agent for TruncateAgent {
capabilities.record_usage(usage.clone()).await; capabilities.record_usage(usage.clone()).await;
// record usage for the session in the session file // record usage for the session in the session file
if let Some(session_id) = session_id.clone() { if let Some(session) = session.clone() {
// TODO: track session_id in langfuse tracing // TODO: track session_id in langfuse tracing
let session_file = session::get_path(session_id); let session_file = session::get_path(session.id);
let mut metadata = session::read_metadata(&session_file)?; let mut metadata = session::read_metadata(&session_file)?;
metadata.working_dir = session.working_dir;
metadata.total_tokens = usage.usage.total_tokens; metadata.total_tokens = usage.usage.total_tokens;
// The message count is the number of messages in the session + 1 for the response // The message count is the number of messages in the session + 1 for the response
// The message count does not include the tool response till next iteration // The message count does not include the tool response till next iteration

View File

@@ -9,9 +9,18 @@ use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
fn get_home_dir() -> PathBuf {
choose_app_strategy(crate::config::APP_STRATEGY.clone())
.expect("goose requires a home dir")
.home_dir()
.to_path_buf()
}
/// Metadata for a session, stored as the first line in the session file /// Metadata for a session, stored as the first line in the session file
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize)]
pub struct SessionMetadata { pub struct SessionMetadata {
/// Working directory for the session
pub working_dir: PathBuf,
/// A short description of the session, typically 3 words or less /// A short description of the session, typically 3 words or less
pub description: String, pub description: String,
/// Number of messages in the session /// Number of messages in the session
@@ -20,9 +29,35 @@ pub struct SessionMetadata {
pub total_tokens: Option<i32>, pub total_tokens: Option<i32>,
} }
// Custom deserializer to handle old sessions without working_dir
impl<'de> Deserialize<'de> for SessionMetadata {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct Helper {
description: String,
message_count: usize,
total_tokens: Option<i32>,
working_dir: Option<PathBuf>,
}
let helper = Helper::deserialize(deserializer)?;
Ok(SessionMetadata {
description: helper.description,
message_count: helper.message_count,
total_tokens: helper.total_tokens,
working_dir: helper.working_dir.unwrap_or_else(get_home_dir),
})
}
}
impl SessionMetadata { impl SessionMetadata {
pub fn new() -> Self { pub fn new(working_dir: PathBuf) -> Self {
Self { Self {
working_dir,
description: String::new(), description: String::new(),
message_count: 0, message_count: 0,
total_tokens: None, total_tokens: None,
@@ -32,7 +67,7 @@ impl SessionMetadata {
impl Default for SessionMetadata { impl Default for SessionMetadata {
fn default() -> Self { fn default() -> Self {
Self::new() Self::new(get_home_dir())
} }
} }
@@ -168,7 +203,7 @@ pub fn read_messages(session_file: &Path) -> Result<Vec<Message>> {
/// Returns default empty metadata if the file doesn't exist or has no metadata. /// Returns default empty metadata if the file doesn't exist or has no metadata.
pub fn read_metadata(session_file: &Path) -> Result<SessionMetadata> { pub fn read_metadata(session_file: &Path) -> Result<SessionMetadata> {
if !session_file.exists() { if !session_file.exists() {
return Ok(SessionMetadata::new()); return Ok(SessionMetadata::default());
} }
let file = fs::File::open(session_file)?; let file = fs::File::open(session_file)?;
@@ -182,12 +217,12 @@ pub fn read_metadata(session_file: &Path) -> Result<SessionMetadata> {
Ok(metadata) => Ok(metadata), Ok(metadata) => Ok(metadata),
Err(_) => { Err(_) => {
// If the first line isn't metadata, return default // If the first line isn't metadata, return default
Ok(SessionMetadata::new()) Ok(SessionMetadata::default())
} }
} }
} else { } else {
// Empty file, return default // Empty file, return default
Ok(SessionMetadata::new()) Ok(SessionMetadata::default())
} }
} }

View File

@@ -11,6 +11,7 @@ import { ConfirmationModal } from './components/ui/ConfirmationModal';
import { ToastContainer } from 'react-toastify'; import { ToastContainer } from 'react-toastify';
import { extractExtensionName } from './components/settings/extensions/utils'; import { extractExtensionName } from './components/settings/extensions/utils';
import { GoosehintsModal } from './components/GoosehintsModal'; import { GoosehintsModal } from './components/GoosehintsModal';
import { SessionDetails, fetchSessionDetails } from './sessions';
import WelcomeView from './components/WelcomeView'; import WelcomeView from './components/WelcomeView';
import ChatView from './components/ChatView'; import ChatView from './components/ChatView';
@@ -37,7 +38,12 @@ export type View =
export type ViewConfig = { export type ViewConfig = {
view: View; view: View;
viewOptions?: SettingsViewOptions | Record<any, any>; viewOptions?:
| SettingsViewOptions
| {
resumedSession?: SessionDetails;
}
| Record<string, any>;
}; };
export default function App() { export default function App() {
@@ -51,6 +57,7 @@ export default function App() {
viewOptions: {}, viewOptions: {},
}); });
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false); const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const { switchModel } = useModel(); const { switchModel } = useModel();
const { addRecentModel } = useRecentModels(); const { addRecentModel } = useRecentModels();
@@ -135,6 +142,7 @@ export default function App() {
addRecentModel(model); addRecentModel(model);
} }
} catch (error) { } catch (error) {
// TODO: add sessionError state and show error screen with option to start fresh
console.error('Failed to initialize with stored provider:', error); console.error('Failed to initialize with stored provider:', error);
} }
} }
@@ -143,6 +151,39 @@ export default function App() {
setupStoredProvider(); setupStoredProvider();
}, []); }, []);
// Check for resumeSessionId in URL parameters
useEffect(() => {
const checkForResumeSession = async () => {
const urlParams = new URLSearchParams(window.location.search);
const resumeSessionId = urlParams.get('resumeSessionId');
if (!resumeSessionId) {
return;
}
setIsLoadingSession(true);
try {
const sessionDetails = await fetchSessionDetails(resumeSessionId);
// Only set view if we have valid session details
if (sessionDetails && sessionDetails.session_id) {
setView('chat', {
resumedSession: sessionDetails,
});
} else {
console.error('Invalid session details received');
}
} catch (error) {
console.error('Failed to fetch session details:', error);
} finally {
// Always clear the loading state
setIsLoadingSession(false);
}
};
checkForResumeSession();
}, []);
useEffect(() => { useEffect(() => {
const handleFatalError = (_: any, errorMessage: string) => { const handleFatalError = (_: any, errorMessage: string) => {
setFatalError(errorMessage); setFatalError(errorMessage);
@@ -160,6 +201,13 @@ export default function App() {
return () => window.electron.off('set-view', handleSetView); return () => window.electron.off('set-view', handleSetView);
}, []); }, []);
// Add cleanup for session states when view changes
useEffect(() => {
if (view !== 'chat') {
setIsLoadingSession(false);
}
}, [view]);
const handleConfirm = async () => { const handleConfirm = async () => {
if (pendingLink && !isInstalling) { if (pendingLink && !isInstalling) {
setIsInstalling(true); setIsInstalling(true);
@@ -250,13 +298,18 @@ export default function App() {
{view === 'alphaConfigureProviders' && ( {view === 'alphaConfigureProviders' && (
<ProviderSettings onClose={() => setView('chat')} /> <ProviderSettings onClose={() => setView('chat')} />
)} )}
{view === 'chat' && ( {view === 'chat' && !isLoadingSession && (
<ChatView <ChatView
setView={setView} setView={setView}
viewOptions={viewOptions} viewOptions={viewOptions}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen} setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
/> />
)} )}
{view === 'chat' && isLoadingSession && (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-textStandard"></div>
</div>
)}
{view === 'sessions' && <SessionsView setView={setView} />} {view === 'sessions' && <SessionsView setView={setView} />}
</div> </div>
</div> </div>

View File

@@ -48,63 +48,28 @@ export default function ChatView({
const resumedSession = viewOptions?.resumedSession; const resumedSession = viewOptions?.resumedSession;
// Generate or retrieve session ID // Generate or retrieve session ID
const [sessionId] = useState(() => { // The session ID should not change for the duration of the chat
// If resuming a session, use that session ID const sessionId = resumedSession?.session_id || generateSessionId();
if (resumedSession?.session_id) {
// Store the resumed session ID in sessionStorage
window.sessionStorage.setItem('goose-session-id', resumedSession.session_id);
return resumedSession.session_id;
}
// For a new chat, generate a new session ID
const newId = generateSessionId();
window.sessionStorage.setItem('goose-session-id', newId);
return newId;
});
const [chat, setChat] = useState<ChatType>(() => { const [chat, setChat] = useState<ChatType>(() => {
// If resuming a session, convert the session messages to our format // If resuming a session, convert the session messages to our format
if (resumedSession) { if (resumedSession) {
try { return {
// Convert the resumed session messages to the expected format id: resumedSession.session_id,
const convertedMessages = resumedSession.messages.map((msg): Message => { title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`,
return { messages: resumedSession.messages,
id: `${msg.role}-${msg.created}`, messageHistoryIndex: resumedSession.messages.length,
role: msg.role, };
created: msg.created,
content: msg.content,
};
});
return {
id: Date.now(),
title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`,
messageHistoryIndex: convertedMessages.length,
messages: convertedMessages,
};
} catch (e) {
console.error('Failed to parse resumed session:', e);
}
} }
// Try to load saved chat from sessionStorage
const savedChat = window.sessionStorage.getItem(`goose-chat-${sessionId}`);
if (savedChat) {
try {
return JSON.parse(savedChat);
} catch (e) {
console.error('Failed to parse saved chat:', e);
}
}
// Return default chat if no saved chat exists
return { return {
id: Date.now(), id: sessionId,
title: 'Chat 1', title: 'New Chat',
messages: [], messages: [],
messageHistoryIndex: 0, messageHistoryIndex: 0,
}; };
}); });
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({}); const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
const [hasMessages, setHasMessages] = useState(false); const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now()); const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
@@ -124,8 +89,8 @@ export default function ChatView({
handleSubmit: _submitMessage, handleSubmit: _submitMessage,
} = useMessageStream({ } = useMessageStream({
api: getApiUrl('/reply'), api: getApiUrl('/reply'),
initialMessages: chat?.messages || [], initialMessages: resumedSession ? resumedSession.messages : chat?.messages || [],
body: { session_id: sessionId }, body: { session_id: sessionId, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
onFinish: async (message, _reason) => { onFinish: async (message, _reason) => {
window.electron.stopPowerSaveBlocker(); window.electron.stopPowerSaveBlocker();
@@ -155,15 +120,9 @@ export default function ChatView({
useEffect(() => { useEffect(() => {
setChat((prevChat) => { setChat((prevChat) => {
const updatedChat = { ...prevChat, messages }; const updatedChat = { ...prevChat, messages };
// Save to sessionStorage
try {
window.sessionStorage.setItem(`goose-chat-${sessionId}`, JSON.stringify(updatedChat));
} catch (e) {
console.error('Failed to save chat to sessionStorage:', e);
}
return updatedChat; return updatedChat;
}); });
}, [messages, sessionId]); }, [messages, sessionId, resumedSession]);
useEffect(() => { useEffect(() => {
if (messages.length > 0) { if (messages.length > 0) {

View File

@@ -247,7 +247,10 @@ export default function MoreMenu({
<button <button
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
window.electron.createChatWindow(); window.electron.createChatWindow(
undefined,
window.appConfig.get('GOOSE_WORKING_DIR')
);
}} }}
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors" className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
> >

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Clock, MessageSquare, ArrowLeft, AlertCircle } from 'lucide-react'; import { Clock, MessageSquare, Folder, AlertCircle } from 'lucide-react';
import { type SessionDetails } from '../../sessions'; import { type SessionDetails } from '../../sessions';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -69,6 +69,10 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
<Clock className="w-4 h-4 mr-1" /> <Clock className="w-4 h-4 mr-1" />
{new Date(session.messages[0]?.created * 1000).toLocaleString()} {new Date(session.messages[0]?.created * 1000).toLocaleString()}
</span> </span>
<span className="flex items-center">
<Folder className="w-4 h-4 mr-1" />
{session.metadata.working_dir}
</span>
<span className="flex items-center"> <span className="flex items-center">
<MessageSquare className="w-4 h-4 mr-1" /> <MessageSquare className="w-4 h-4 mr-1" />
{session.metadata.message_count} messages {session.metadata.message_count} messages

View File

@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { ViewConfig } from '../../App'; import { ViewConfig } from '../../App';
import { MessageSquare, Loader, AlertCircle, Calendar, ChevronRight } from 'lucide-react'; import { MessageSquare, Loader, AlertCircle, Calendar, ChevronRight, Folder } from 'lucide-react';
import { fetchSessions, type Session } from '../../sessions'; import { fetchSessions, type Session } from '../../sessions';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -104,9 +104,15 @@ const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSess
<h3 className="text-base font-medium text-textStandard truncate"> <h3 className="text-base font-medium text-textStandard truncate">
{session.metadata.description || session.id} {session.metadata.description || session.id}
</h3> </h3>
<div className="flex items-center mt-1 text-textSubtle text-sm"> <div className="flex gap-3">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" /> <div className="flex items-center text-textSubtle text-sm">
<span className="truncate">{formatDate(session.modified)}</span> <Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{formatDate(session.modified)}</span>
</div>
<div className="flex items-center text-textSubtle text-sm">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.metadata.working_dir}</span>
</div>
</div> </div>
</div> </div>

View File

@@ -40,10 +40,26 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const handleResumeSession = () => { const handleResumeSession = () => {
if (selectedSession) { if (selectedSession) {
// Pass the session to ChatView for resuming // Get the working directory from the session metadata
setView('chat', { const workingDir = selectedSession.metadata.working_dir;
resumedSession: selectedSession,
}); if (workingDir) {
console.log(
`Resuming session with ID: ${selectedSession.session_id}, in working dir: ${workingDir}`
);
// Create a new chat window with the working directory and session ID
window.electron.createChatWindow(
undefined,
workingDir,
undefined,
selectedSession.session_id
);
} else {
// Fallback if no working directory is found
console.error('No working directory found in session metadata');
// We could show a toast or alert here
}
} }
}; };

View File

@@ -268,8 +268,11 @@ export function useMessageStream({
const abortController = new AbortController(); const abortController = new AbortController();
abortControllerRef.current = abortController; abortControllerRef.current = abortController;
// Log the request messages for debugging // Log request details for debugging
console.log('Sending messages to server:', JSON.stringify(requestMessages, null, 2)); console.log('Request details:', {
messages: requestMessages,
body: extraMetadataRef.current.body,
});
// Send request to the server // Send request to the server
const response = await fetch(api, { const response = await fetch(api, {

View File

@@ -115,7 +115,13 @@ let appConfig = {
let windowCounter = 0; let windowCounter = 0;
const windowMap = new Map<number, BrowserWindow>(); const windowMap = new Map<number, BrowserWindow>();
const createChat = async (app, query?: string, dir?: string, version?: string) => { const createChat = async (
app,
query?: string,
dir?: string,
version?: string,
resumeSessionId?: string
) => {
// Apply current environment settings before creating chat // Apply current environment settings before creating chat
updateEnvironmentVariables(envToggles); updateEnvironmentVariables(envToggles);
@@ -158,7 +164,18 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
}); });
// Load the index.html of the app. // Load the index.html of the app.
const queryParam = query ? `?initialQuery=${encodeURIComponent(query)}` : ''; let queryParams = '';
if (query) {
queryParams = `?initialQuery=${encodeURIComponent(query)}`;
}
// Add resumeSessionId to query params if provided
if (resumeSessionId) {
queryParams = queryParams
? `${queryParams}&resumeSessionId=${encodeURIComponent(resumeSessionId)}`
: `?resumeSessionId=${encodeURIComponent(resumeSessionId)}`;
}
const primaryDisplay = electron.screen.getPrimaryDisplay(); const primaryDisplay = electron.screen.getPrimaryDisplay();
const { width } = primaryDisplay.workAreaSize; const { width } = primaryDisplay.workAreaSize;
@@ -173,13 +190,13 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
mainWindow.setPosition(baseXPosition + xOffset, 100); mainWindow.setPosition(baseXPosition + xOffset, 100);
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParam}`); mainWindow.loadURL(`${MAIN_WINDOW_VITE_DEV_SERVER_URL}${queryParams}`);
} else { } else {
// In production, we need to use a proper file protocol URL with correct base path // In production, we need to use a proper file protocol URL with correct base path
const indexPath = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`); const indexPath = path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/index.html`);
console.log('Loading production path:', indexPath); console.log('Loading production path:', indexPath);
mainWindow.loadFile(indexPath, { mainWindow.loadFile(indexPath, {
search: queryParam ? queryParam.slice(1) : undefined, search: queryParams ? queryParams.slice(1) : undefined,
}); });
} }
@@ -460,12 +477,12 @@ app.whenReady().then(async () => {
} }
}); });
ipcMain.on('create-chat-window', (_, query, dir, version) => { ipcMain.on('create-chat-window', (_, query, dir, version, resumeSessionId) => {
if (!dir?.trim()) { if (!dir?.trim()) {
const recentDirs = loadRecentDirs(); const recentDirs = loadRecentDirs();
dir = recentDirs.length > 0 ? recentDirs[0] : null; dir = recentDirs.length > 0 ? recentDirs[0] : null;
} }
createChat(app, query, dir, version); createChat(app, query, dir, version, resumeSessionId);
}); });
ipcMain.on('directory-chooser', (_, replace: boolean = false) => { ipcMain.on('directory-chooser', (_, replace: boolean = false) => {

View File

@@ -7,7 +7,12 @@ type ElectronAPI = {
getConfig: () => Record<string, any>; getConfig: () => Record<string, any>;
hideWindow: () => void; hideWindow: () => void;
directoryChooser: (replace: string) => void; directoryChooser: (replace: string) => void;
createChatWindow: (query?: string, dir?: string, version?: string) => void; createChatWindow: (
query?: string,
dir?: string,
version?: string,
resumeSessionId?: string
) => void;
logInfo: (txt: string) => void; logInfo: (txt: string) => void;
showNotification: (data: any) => void; showNotification: (data: any) => void;
openInChrome: (url: string) => void; openInChrome: (url: string) => void;
@@ -18,7 +23,9 @@ type ElectronAPI = {
startPowerSaveBlocker: () => Promise<number>; startPowerSaveBlocker: () => Promise<number>;
stopPowerSaveBlocker: () => Promise<void>; stopPowerSaveBlocker: () => Promise<void>;
getBinaryPath: (binaryName: string) => Promise<string>; getBinaryPath: (binaryName: string) => Promise<string>;
readFile: (directory: string) => Promise<{ file: string; filePath: string; error: string; found: boolean }>; readFile: (
directory: string
) => Promise<{ file: string; filePath: string; error: string; found: boolean }>;
writeFile: (directory: string, content: string) => Promise<boolean>; writeFile: (directory: string, content: string) => Promise<boolean>;
on: ( on: (
channel: string, channel: string,
@@ -40,8 +47,8 @@ const electronAPI: ElectronAPI = {
getConfig: () => config, getConfig: () => config,
hideWindow: () => ipcRenderer.send('hide-window'), hideWindow: () => ipcRenderer.send('hide-window'),
directoryChooser: (replace: string) => ipcRenderer.send('directory-chooser', replace), directoryChooser: (replace: string) => ipcRenderer.send('directory-chooser', replace),
createChatWindow: (query?: string, dir?: string, version?: string) => createChatWindow: (query?: string, dir?: string, version?: string, resumeSessionId?: string) =>
ipcRenderer.send('create-chat-window', query, dir, version), ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId),
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt), logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
showNotification: (data: any) => ipcRenderer.send('notify', data), showNotification: (data: any) => ipcRenderer.send('notify', data),
openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url), openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url),
@@ -53,7 +60,8 @@ const electronAPI: ElectronAPI = {
stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'), stopPowerSaveBlocker: () => ipcRenderer.invoke('stop-power-save-blocker'),
getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName), getBinaryPath: (binaryName: string) => ipcRenderer.invoke('get-binary-path', binaryName),
readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath), readFile: (filePath: string) => ipcRenderer.invoke('read-file', filePath),
writeFile: (filePath: string, content: string) => ipcRenderer.invoke('write-file', filePath, content), writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
on: (channel: string, callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => { on: (channel: string, callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
ipcRenderer.on(channel, callback); ipcRenderer.on(channel, callback);
}, },

View File

@@ -4,6 +4,17 @@ export interface SessionMetadata {
description: string; description: string;
message_count: number; message_count: number;
total_tokens: number | null; total_tokens: number | null;
working_dir: string; // Required in type, but may be missing in old sessions
}
// Helper function to ensure working directory is set
export function ensureWorkingDir(metadata: Partial<SessionMetadata>): SessionMetadata {
return {
description: metadata.description || '',
message_count: metadata.message_count || 0,
total_tokens: metadata.total_tokens || null,
working_dir: metadata.working_dir || process.env.HOME || '',
};
} }
export interface Session { export interface Session {
@@ -67,9 +78,13 @@ export async function fetchSessions(): Promise<SessionsResponse> {
// TODO: remove this logic once everyone migrates to the new sessions format // TODO: remove this logic once everyone migrates to the new sessions format
// for now, filter out sessions whose description is empty (old CLI sessions) // for now, filter out sessions whose description is empty (old CLI sessions)
const sessions = (await response.json()).sessions.filter( const rawSessions = await response.json();
(session: Session) => session.metadata.description !== '' const sessions = rawSessions.sessions
); .filter((session: Session) => session.metadata.description !== '')
.map((session: Session) => ({
...session,
metadata: ensureWorkingDir(session.metadata),
}));
// order sessions by 'modified' date descending // order sessions by 'modified' date descending
sessions.sort( sessions.sort(
@@ -102,7 +117,11 @@ export async function fetchSessionDetails(sessionId: string): Promise<SessionDet
throw new Error(`Failed to fetch session details: ${response.status} ${response.statusText}`); throw new Error(`Failed to fetch session details: ${response.status} ${response.statusText}`);
} }
return await response.json(); const details = await response.json();
return {
...details,
metadata: ensureWorkingDir(details.metadata),
};
} catch (error) { } catch (error) {
console.error(`Error fetching session details for ${sessionId}:`, error); console.error(`Error fetching session details for ${sessionId}:`, error);
throw error; throw error;