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
+55 -2
View File
@@ -11,6 +11,7 @@ import { ConfirmationModal } from './components/ui/ConfirmationModal';
import { ToastContainer } from 'react-toastify';
import { extractExtensionName } from './components/settings/extensions/utils';
import { GoosehintsModal } from './components/GoosehintsModal';
import { SessionDetails, fetchSessionDetails } from './sessions';
import WelcomeView from './components/WelcomeView';
import ChatView from './components/ChatView';
@@ -37,7 +38,12 @@ export type View =
export type ViewConfig = {
view: View;
viewOptions?: SettingsViewOptions | Record<any, any>;
viewOptions?:
| SettingsViewOptions
| {
resumedSession?: SessionDetails;
}
| Record<string, any>;
};
export default function App() {
@@ -51,6 +57,7 @@ export default function App() {
viewOptions: {},
});
const [isGoosehintsModalOpen, setIsGoosehintsModalOpen] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const { switchModel } = useModel();
const { addRecentModel } = useRecentModels();
@@ -135,6 +142,7 @@ export default function App() {
addRecentModel(model);
}
} catch (error) {
// TODO: add sessionError state and show error screen with option to start fresh
console.error('Failed to initialize with stored provider:', error);
}
}
@@ -143,6 +151,39 @@ export default function App() {
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(() => {
const handleFatalError = (_: any, errorMessage: string) => {
setFatalError(errorMessage);
@@ -160,6 +201,13 @@ export default function App() {
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 () => {
if (pendingLink && !isInstalling) {
setIsInstalling(true);
@@ -250,13 +298,18 @@ export default function App() {
{view === 'alphaConfigureProviders' && (
<ProviderSettings onClose={() => setView('chat')} />
)}
{view === 'chat' && (
{view === 'chat' && !isLoadingSession && (
<ChatView
setView={setView}
viewOptions={viewOptions}
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} />}
</div>
</div>
+14 -55
View File
@@ -48,63 +48,28 @@ export default function ChatView({
const resumedSession = viewOptions?.resumedSession;
// Generate or retrieve session ID
const [sessionId] = useState(() => {
// If resuming a session, use that session ID
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;
});
// The session ID should not change for the duration of the chat
const sessionId = resumedSession?.session_id || generateSessionId();
const [chat, setChat] = useState<ChatType>(() => {
// If resuming a session, convert the session messages to our format
if (resumedSession) {
try {
// Convert the resumed session messages to the expected format
const convertedMessages = resumedSession.messages.map((msg): Message => {
return {
id: `${msg.role}-${msg.created}`,
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);
}
return {
id: resumedSession.session_id,
title: resumedSession.metadata?.description || `ID: ${resumedSession.session_id}`,
messages: resumedSession.messages,
messageHistoryIndex: resumedSession.messages.length,
};
}
// 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 {
id: Date.now(),
title: 'Chat 1',
id: sessionId,
title: 'New Chat',
messages: [],
messageHistoryIndex: 0,
};
});
const [messageMetadata, setMessageMetadata] = useState<Record<string, string[]>>({});
const [hasMessages, setHasMessages] = useState(false);
const [lastInteractionTime, setLastInteractionTime] = useState<number>(Date.now());
@@ -124,8 +89,8 @@ export default function ChatView({
handleSubmit: _submitMessage,
} = useMessageStream({
api: getApiUrl('/reply'),
initialMessages: chat?.messages || [],
body: { session_id: sessionId },
initialMessages: resumedSession ? resumedSession.messages : chat?.messages || [],
body: { session_id: sessionId, session_working_dir: window.appConfig.get('GOOSE_WORKING_DIR') },
onFinish: async (message, _reason) => {
window.electron.stopPowerSaveBlocker();
@@ -155,15 +120,9 @@ export default function ChatView({
useEffect(() => {
setChat((prevChat) => {
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;
});
}, [messages, sessionId]);
}, [messages, sessionId, resumedSession]);
useEffect(() => {
if (messages.length > 0) {
+4 -1
View File
@@ -247,7 +247,10 @@ export default function MoreMenu({
<button
onClick={() => {
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"
>
@@ -1,5 +1,5 @@
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 { Card } from '../ui/card';
import { Button } from '../ui/button';
@@ -69,6 +69,10 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
<Clock className="w-4 h-4 mr-1" />
{new Date(session.messages[0]?.created * 1000).toLocaleString()}
</span>
<span className="flex items-center">
<Folder className="w-4 h-4 mr-1" />
{session.metadata.working_dir}
</span>
<span className="flex items-center">
<MessageSquare className="w-4 h-4 mr-1" />
{session.metadata.message_count} messages
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react';
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 { Card } from '../ui/card';
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">
{session.metadata.description || session.id}
</h3>
<div className="flex items-center mt-1 text-textSubtle text-sm">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{formatDate(session.modified)}</span>
<div className="flex gap-3">
<div className="flex items-center text-textSubtle text-sm">
<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>
@@ -40,10 +40,26 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const handleResumeSession = () => {
if (selectedSession) {
// Pass the session to ChatView for resuming
setView('chat', {
resumedSession: selectedSession,
});
// Get the working directory from the session metadata
const workingDir = selectedSession.metadata.working_dir;
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
}
}
};
+5 -2
View File
@@ -268,8 +268,11 @@ export function useMessageStream({
const abortController = new AbortController();
abortControllerRef.current = abortController;
// Log the request messages for debugging
console.log('Sending messages to server:', JSON.stringify(requestMessages, null, 2));
// Log request details for debugging
console.log('Request details:', {
messages: requestMessages,
body: extraMetadataRef.current.body,
});
// Send request to the server
const response = await fetch(api, {
+23 -6
View File
@@ -115,7 +115,13 @@ let appConfig = {
let windowCounter = 0;
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
updateEnvironmentVariables(envToggles);
@@ -158,7 +164,18 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
});
// 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 { width } = primaryDisplay.workAreaSize;
@@ -173,13 +190,13 @@ const createChat = async (app, query?: string, dir?: string, version?: string) =
mainWindow.setPosition(baseXPosition + xOffset, 100);
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 {
// 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`);
console.log('Loading production path:', 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()) {
const recentDirs = loadRecentDirs();
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) => {
+13 -5
View File
@@ -7,7 +7,12 @@ type ElectronAPI = {
getConfig: () => Record<string, any>;
hideWindow: () => 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;
showNotification: (data: any) => void;
openInChrome: (url: string) => void;
@@ -18,7 +23,9 @@ type ElectronAPI = {
startPowerSaveBlocker: () => Promise<number>;
stopPowerSaveBlocker: () => Promise<void>;
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>;
on: (
channel: string,
@@ -40,8 +47,8 @@ const electronAPI: ElectronAPI = {
getConfig: () => config,
hideWindow: () => ipcRenderer.send('hide-window'),
directoryChooser: (replace: string) => ipcRenderer.send('directory-chooser', replace),
createChatWindow: (query?: string, dir?: string, version?: string) =>
ipcRenderer.send('create-chat-window', query, dir, version),
createChatWindow: (query?: string, dir?: string, version?: string, resumeSessionId?: string) =>
ipcRenderer.send('create-chat-window', query, dir, version, resumeSessionId),
logInfo: (txt: string) => ipcRenderer.send('logInfo', txt),
showNotification: (data: any) => ipcRenderer.send('notify', data),
openInChrome: (url: string) => ipcRenderer.send('open-in-chrome', url),
@@ -53,7 +60,8 @@ const electronAPI: ElectronAPI = {
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),
writeFile: (filePath: string, content: string) =>
ipcRenderer.invoke('write-file', filePath, content),
on: (channel: string, callback: (event: Electron.IpcRendererEvent, ...args: any[]) => void) => {
ipcRenderer.on(channel, callback);
},
+23 -4
View File
@@ -4,6 +4,17 @@ export interface SessionMetadata {
description: string;
message_count: number;
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 {
@@ -67,9 +78,13 @@ export async function fetchSessions(): Promise<SessionsResponse> {
// TODO: remove this logic once everyone migrates to the new sessions format
// for now, filter out sessions whose description is empty (old CLI sessions)
const sessions = (await response.json()).sessions.filter(
(session: Session) => session.metadata.description !== ''
);
const rawSessions = await response.json();
const sessions = rawSessions.sessions
.filter((session: Session) => session.metadata.description !== '')
.map((session: Session) => ({
...session,
metadata: ensureWorkingDir(session.metadata),
}));
// order sessions by 'modified' date descending
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}`);
}
return await response.json();
const details = await response.json();
return {
...details,
metadata: ensureWorkingDir(details.metadata),
};
} catch (error) {
console.error(`Error fetching session details for ${sessionId}:`, error);
throw error;