feat: store working directory for sessions (#1559)
This commit is contained in:
+55
-2
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user