feat: sessions api, view & resume prev sessions (#1453)

* Centralize session files to goose::session module
* Write session metadata and messages in jsonl
* Refactor CLI build_session to use goose::session functions
* Track session's token usage by adding optional session_id in agent.reply(...)
* NOTE: Only sessions saved through the updates goose::session functions will show up in GUI

Co-authored-by: Bradley Axen <baxen@squareup.com>
This commit is contained in:
Salman Mohammed
2025-03-03 11:49:15 -05:00
committed by GitHub
parent 68b8c5d19d
commit 9ae9045584
25 changed files with 1413 additions and 257 deletions
+78 -9
View File
@@ -1,14 +1,83 @@
You are an expert programmer in electron, with typescript, electron forge and vite and vercel AI sdk, who is teaching another developer who is experienced but not always familiar with these technologies for this desktop app.
## Project Overview
The Goose Desktop App is an Electron application built with TypeScript, React, and modern web technologies. It's a chat interface application that connects to various AI providers and allows users to interact with AI models.
Key Principles
- Write clear, concise, and idiomatic code with accurate examples.
- Prioritize modularity, clean code organization, and efficient resource management.
- ask the user to verify the UI, and try to test it as well.
## Key Technologies
Look at package.json for how to build and run (eg npm start if checking with user)
./src has most of the code
- **Electron**: For cross-platform desktop app functionality
- **React**: For UI components and state management
- **TypeScript**: For type-safe code
- **Tailwind CSS**: For styling components
- **Vite**: For fast development and bundling
- **Electron Forge**: For packaging and distribution
To validate changes:
## Project Structure
`npm run test-e2e` is a good way to get a feedback loop
- `/src`: Main source code directory
- `/main.ts`: Electron main process entry point
- `/preload.ts`: Preload script for secure renderer access
- `/renderer.tsx`: React entry point for the renderer process
- `/App.tsx`: Main React component that manages views
- `/components`: React components (page views, UI components, icons)
- `/api`: API client code
- `/utils`: Utility functions
- `/hooks`: React hooks
- `/types`: TypeScript type definitions
- `/styles`: CSS and styling
- `/images`: Image assets
## Getting Started
1. **Understand the application flow**:
- `main.ts` is the Electron entry point that creates windows and handles IPC
- `renderer.tsx` bootstraps the React application
- `App.tsx` manages the different views (chat, settings, etc.)
- The app uses a view-based navigation system with components conditionally rendered based on the current view
2. **Adding a new feature**:
- Create a new component in the `/components` directory
- Add the component to the view system in `App.tsx` by:
- Adding a new view type to the `View` type
- Importing your component
- Adding a conditional render in the App component
3. **Building and testing**:
- Use `npm run start-gui` to run the app in development mode
- Changes to React components will hot reload
- Changes to main process code require a restart
## Adding a New View/Component
1. Create a new directory under `/src/components` for your feature
2. Create a main component file (e.g., `YourFeatureView.tsx`)
3. Add your view type to the `View` type in `App.tsx`
4. Import and add your component to the render section in `App.tsx`
5. Add navigation to your view from other components (e.g., adding a button in `BottomMenu.tsx` or `MoreMenu.tsx`)
## State Management
- The app uses React's Context API for global state
- Look at existing contexts like `ConfigContext.tsx` and `ModelContext.tsx` for examples
- For local state, use React hooks like `useState` and `useEffect`
## Styling
- The app uses Tailwind CSS for styling
- Custom UI components are in `/components/ui`
- Follow the existing design patterns for consistency
## IPC Communication
- The app uses Electron's IPC for communication between main and renderer processes
- The `window.electron` object (defined in preload.ts) provides access to IPC methods
- Use existing patterns for adding new IPC functionality
## Best Practices
1. Use TypeScript types for all props and state
2. Follow the existing component structure and patterns
3. Use existing UI components when possible
4. Handle errors gracefully
5. Test your changes in both development and production builds
By following these instructions, you should be able to navigate the codebase, understand its structure, and start contributing new features or modifications.
+5 -2
View File
@@ -17,6 +17,7 @@ import SettingsView, { type SettingsViewOptions } from './components/settings/Se
import SettingsViewV2 from './components/settings_v2/SettingsView';
import MoreModelsView from './components/settings/models/MoreModelsView';
import ConfigureProvidersView from './components/settings/providers/ConfigureProvidersView';
import SessionsView from './components/sessions/SessionsView';
import ProviderSettings from './components/settings_v2/providers/ProviderSettingsPage';
import 'react-toastify/dist/ReactToastify.css';
@@ -30,7 +31,8 @@ export type View =
| 'configureProviders'
| 'configPage'
| 'alphaConfigureProviders'
| 'settingsV2';
| 'settingsV2'
| 'sessions';
export type ViewConfig = {
view: View;
@@ -246,7 +248,8 @@ export default function App() {
{view === 'alphaConfigureProviders' && (
<ProviderSettings onClose={() => setView('chat')} />
)}
{view === 'chat' && <ChatView setView={setView} />}
{view === 'chat' && <ChatView setView={setView} viewOptions={viewOptions} />}
{view === 'sessions' && <SessionsView setView={setView} />}
</div>
</div>
</>
+48 -12
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { getApiUrl } from '../config';
import { v4 as uuidv4 } from 'uuid';
import { generateSessionId } from '../sessions';
import BottomMenu from './BottomMenu';
import FlappyGoose from './FlappyGoose';
import GooseMessage from './GooseMessage';
@@ -23,23 +23,58 @@ export interface ChatType {
messages: Message[];
}
export default function ChatView({ setView }: { setView: (view: View) => void }) {
// Generate or retrieve a unique window ID
const [windowId] = useState(() => {
// Check if we already have a window ID in sessionStorage
const existingId = window.sessionStorage.getItem('goose-window-id');
export default function ChatView({
setView,
viewOptions,
}: {
setView: (view: View, viewOptions?: Record<any, any>) => void;
viewOptions?: Record<any, any>;
}) {
// Check if we're resuming a session
const resumedSession = viewOptions?.resumedSession;
// Generate or retrieve session ID
const [sessionId] = useState(() => {
// If resuming a session, use that session ID
if (resumedSession?.session_id) {
return resumedSession.session_id;
}
const existingId = window.sessionStorage.getItem('goose-session-id');
if (existingId) {
return existingId;
}
// Create a new ID if none exists
const newId = uuidv4();
window.sessionStorage.setItem('goose-window-id', newId);
const newId = generateSessionId();
window.sessionStorage.setItem('goose-session-id', newId);
return newId;
});
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.description || `Chat ${resumedSession.session_id}`,
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-${windowId}`);
const savedChat = window.sessionStorage.getItem(`goose-chat-${sessionId}`);
if (savedChat) {
try {
return JSON.parse(savedChat);
@@ -75,6 +110,7 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
} = useMessageStream({
api: getApiUrl('/reply'),
initialMessages: chat?.messages || [],
body: { session_id: sessionId },
onFinish: async (message, _reason) => {
window.electron.stopPowerSaveBlocker();
@@ -106,13 +142,13 @@ export default function ChatView({ setView }: { setView: (view: View) => void })
const updatedChat = { ...prevChat, messages };
// Save to sessionStorage
try {
window.sessionStorage.setItem(`goose-chat-${windowId}`, JSON.stringify(updatedChat));
window.sessionStorage.setItem(`goose-chat-${sessionId}`, JSON.stringify(updatedChat));
} catch (e) {
console.error('Failed to save chat to sessionStorage:', e);
}
return updatedChat;
});
}, [messages, windowId]);
}, [messages, sessionId]);
useEffect(() => {
if (messages.length > 0) {
+8
View File
@@ -220,6 +220,14 @@ export default function MoreMenu({ setView }: { setView: (view: View) => void })
</>
)}
{/* View Previous Sessions */}
<button
className="w-full text-left p-2 text-sm hover:bg-bgSubtle transition-colors"
onClick={() => setView('sessions')}
>
<span>Previous Sessions</span>
</button>
{/* Settings Menu */}
<button
onClick={() => {
@@ -0,0 +1,196 @@
import React from 'react';
import { Clock, MessageSquare, ArrowLeft, AlertCircle } from 'lucide-react';
import { type SessionDetails } from '../../sessions';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
import MarkdownContent from '../MarkdownContent';
import ToolCallWithResponse from '../ToolCallWithResponse';
import { ToolRequestMessageContent, ToolResponseMessageContent } from '../../types/message';
interface SessionHistoryViewProps {
session: SessionDetails;
isLoading: boolean;
error: string | null;
onBack: () => void;
onResume: () => void;
onRetry: () => void;
}
export const getToolResponsesMap = (
session: SessionDetails,
messageIndex: number,
toolRequests: ToolRequestMessageContent[]
) => {
const responseMap = new Map();
if (messageIndex >= 0) {
for (let i = messageIndex + 1; i < session.messages.length; i++) {
const responses = session.messages[i].content
.filter((c) => c.type === 'toolResponse')
.map((c) => c as ToolResponseMessageContent);
for (const response of responses) {
const matchingRequest = toolRequests.find((req) => req.id === response.id);
if (matchingRequest) {
responseMap.set(response.id, response);
}
}
}
}
return responseMap;
};
const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
session,
isLoading,
error,
onBack,
onResume,
onRetry,
}) => {
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
{/* Top Row - back, info, reopen thread (fixed) */}
<Card className="px-8 pt-6 pb-4 bg-bgSecondary flex items-center">
<BackButton showText={false} onClick={onBack} className="text-textStandard" />
{/* Session info row */}
<div className="ml-8">
<h1 className="text-lg font-bold text-textStandard">
{session.metadata.description || session.session_id}
</h1>
<div className="flex items-center text-sm text-textSubtle mt-2 space-x-4">
<span className="flex items-center">
<Clock className="w-4 h-4 mr-1" />
{new Date(session.messages[0]?.created * 1000).toLocaleString()}
</span>
<span className="flex items-center">
<MessageSquare className="w-4 h-4 mr-1" />
{session.metadata.message_count} messages
</span>
{session.metadata.total_tokens !== null && (
<span className="flex items-center">
{session.metadata.total_tokens.toLocaleString()} tokens
</span>
)}
</div>
</div>
<span
onClick={onResume}
className="ml-auto text-md cursor-pointer text-textStandard hover:font-bold hover:scale-105 transition-all duration-150"
>
Resume Session
</span>
</Card>
<ScrollArea className="h-[calc(100vh-120px)] w-full">
{/* Content */}
<div className="p-4">
<div className="flex flex-col space-y-4">
<div className="space-y-4 mb-6">
{isLoading ? (
<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>
) : error ? (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<div className="text-red-500 mb-4">
<AlertCircle size={32} />
</div>
<p className="text-md mb-2">Error Loading Session Details</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={onRetry} variant="default">
Try Again
</Button>
</div>
) : session?.messages?.length > 0 ? (
session.messages
.map((message, index) => {
// Extract text content from the message
const textContent = message.content
.filter((c) => c.type === 'text')
.map((c) => c.text)
.join('\n');
// Get tool requests from the message
const toolRequests = message.content
.filter((c) => c.type === 'toolRequest')
.map((c) => c as ToolRequestMessageContent);
// Get tool responses map using the helper function
const toolResponsesMap = getToolResponsesMap(session, index, toolRequests);
// Skip pure tool response messages for cleaner display
const isOnlyToolResponse =
message.content.length > 0 &&
message.content.every((c) => c.type === 'toolResponse');
if (message.role === 'user' && isOnlyToolResponse) {
return null;
}
return (
<Card
key={index}
className={`p-4 ${
message.role === 'user'
? 'bg-bgSecondary border border-borderSubtle'
: 'bg-bgSubtle'
}`}
>
<div className="flex justify-between items-center mb-2">
<span className="font-medium text-textStandard">
{message.role === 'user' ? 'You' : 'Goose'}
</span>
<span className="text-xs text-textSubtle">
{new Date(message.created * 1000).toLocaleTimeString()}
</span>
</div>
<div className="flex flex-col w-full">
{/* Text content */}
{textContent && (
<div className={`${toolRequests.length > 0 ? 'mb-4' : ''}`}>
<MarkdownContent content={textContent} />
</div>
)}
{/* Tool requests and responses */}
{toolRequests.length > 0 && (
<div className="goose-message-tool bg-bgApp border border-borderSubtle dark:border-gray-700 rounded-b-2xl px-4 pt-4 pb-2 mt-1">
{toolRequests.map((toolRequest) => (
<ToolCallWithResponse
key={toolRequest.id}
toolRequest={toolRequest}
toolResponse={toolResponsesMap.get(toolRequest.id)}
/>
))}
</div>
)}
</div>
</Card>
);
})
.filter(Boolean) // Filter out null entries
) : (
<div className="flex flex-col items-center justify-center py-8 text-textSubtle">
<MessageSquare className="w-12 h-12 mb-4" />
<p className="text-lg mb-2">No messages found</p>
<p className="text-sm">This session doesn't contain any messages</p>
</div>
)}
</div>
</div>
</div>
</ScrollArea>
</div>
);
};
export default SessionHistoryView;
@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { ViewConfig } from '../../App';
import { MessageSquare, Loader, AlertCircle, Calendar, ChevronRight } from 'lucide-react';
import { fetchSessions, type Session } from '../../sessions';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import BackButton from '../ui/BackButton';
import { ScrollArea } from '../ui/scroll-area';
interface SessionListViewProps {
setView: (view: ViewConfig['view'], viewOptions?: Record<any, any>) => void;
onSelectSession: (sessionId: string) => void;
}
const SessionListView: React.FC<SessionListViewProps> = ({ setView, onSelectSession }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
// Load sessions on component mount
loadSessions();
}, []);
const loadSessions = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetchSessions();
setSessions(response.sessions);
} catch (err) {
console.error('Failed to load sessions:', err);
setError('Failed to load sessions. Please try again later.');
setSessions([]);
} finally {
setIsLoading(false);
}
};
// Format date to be more readable
// eg. "10:39 PM, Feb 28, 2025"
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString);
const time = new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: 'numeric',
hour12: true,
}).format(date);
const dateStr = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(date);
return `${time}, ${dateStr}`;
} catch (e) {
return dateString;
}
};
return (
<div className="h-screen w-full">
<div className="relative flex items-center h-[36px] w-full bg-bgSubtle"></div>
<ScrollArea className="h-full w-full">
<div className="flex flex-col pb-24">
<div className="px-8 pt-6 pb-4">
<BackButton onClick={() => setView('chat')} />
</div>
{/* Content Area */}
<div className="flex flex-col mb-6 px-8">
<h1 className="text-3xl font-medium text-textStandard">Previous goose sessions</h1>
<h3 className="text-sm text-textSubtle mt-2">
View previous goose sessions and their contents to pick up where you left off.
</h3>
</div>
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex justify-center items-center h-full">
<Loader className="h-8 w-8 animate-spin text-textPrimary" />
</div>
) : error ? (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<AlertCircle className="h-12 w-12 text-red-500 mb-4" />
<p className="text-lg mb-2">Error Loading Sessions</p>
<p className="text-sm text-center mb-4">{error}</p>
<Button onClick={loadSessions} variant="default">
Try Again
</Button>
</div>
) : sessions.length > 0 ? (
<div className="grid gap-2">
{sessions.map((session) => (
<Card
key={session.id}
onClick={() => onSelectSession(session.id)}
className="p-2 bg-bgSecondary hover:bg-bgSubtle cursor-pointer transition-all duration-150"
>
<div className="flex justify-between items-start">
<div className="w-full">
<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>
</div>
<div className="flex items-center gap-3">
<div className="flex flex-col items-end">
<div className="flex items-center text-sm text-textSubtle">
<span>{session.path.split('/').pop() || session.path}</span>
</div>
<div className="flex items-center mt-1 space-x-3 text-sm text-textSubtle">
<div className="flex items-center">
<MessageSquare className="w-3 h-3 mr-1" />
<span>{session.metadata.message_count}</span>
</div>
{session.metadata.total_tokens !== null && (
<div className="flex items-center">
<span>{session.metadata.total_tokens.toLocaleString()} tokens</span>
</div>
)}
</div>
</div>
<ChevronRight className="w-8 h-5 text-textSubtle" />
</div>
</div>
</Card>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-textSubtle">
<MessageSquare className="h-12 w-12 mb-4" />
<p className="text-lg mb-2">No chat sessions found</p>
<p className="text-sm">Your chat history will appear here</p>
</div>
)}
</div>
</div>
</ScrollArea>
</div>
);
};
export default SessionListView;
@@ -0,0 +1,72 @@
import React, { useState } from 'react';
import { ViewConfig } from '../../App';
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
interface SessionsViewProps {
setView: (view: ViewConfig['view'], viewOptions?: Record<any, any>) => void;
}
const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const [selectedSession, setSelectedSession] = useState<SessionDetails | null>(null);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSelectSession = async (sessionId: string) => {
await loadSessionDetails(sessionId);
};
const loadSessionDetails = async (sessionId: string) => {
setIsLoadingSession(true);
setError(null);
try {
const sessionDetails = await fetchSessionDetails(sessionId);
setSelectedSession(sessionDetails);
} catch (err) {
console.error(`Failed to load session details for ${sessionId}:`, err);
setError('Failed to load session details. Please try again later.');
// Keep the selected session null if there's an error
setSelectedSession(null);
} finally {
setIsLoadingSession(false);
}
};
const handleBackToSessions = () => {
setSelectedSession(null);
setError(null);
};
const handleResumeSession = () => {
if (selectedSession) {
// Pass the session to ChatView for resuming
setView('chat', {
resumedSession: selectedSession,
});
}
};
const handleRetryLoadSession = () => {
if (selectedSession) {
loadSessionDetails(selectedSession.session_id);
}
};
// If a session is selected, show the session history view
// Otherwise, show the sessions list view
return selectedSession ? (
<SessionHistoryView
session={selectedSession}
isLoading={isLoadingSession}
error={error}
onBack={handleBackToSessions}
onResume={handleResumeSession}
onRetry={handleRetryLoadSession}
/>
) : (
<SessionListView setView={setView} onSelectSession={handleSelectSession} />
);
};
export default SessionsView;
+10 -3
View File
@@ -4,9 +4,16 @@ import Back from '../icons/Back';
interface BackButtonProps {
onClick?: () => void; // Mark onClick as optional
className?: string;
textSize?: 'sm' | 'base' | 'md' | 'lg';
showText?: boolean; // Add new prop
}
const BackButton: React.FC<BackButtonProps> = ({ onClick, className = '' }) => {
const BackButton: React.FC<BackButtonProps> = ({
onClick,
className = '',
textSize = 'sm',
showText = true,
}) => {
const handleExit = () => {
if (onClick) {
onClick(); // Custom onClick handler passed via props
@@ -20,10 +27,10 @@ const BackButton: React.FC<BackButtonProps> = ({ onClick, className = '' }) => {
return (
<button
onClick={handleExit}
className={`flex items-center text-sm text-textSubtle group hover:text-textStandard ${className}`}
className={`flex items-center text-${textSize} text-textSubtle group hover:text-textStandard ${className}`}
>
<Back className="w-3 h-3 group-hover:-translate-x-1 transition-all mr-1" />
<span>Exit</span>
{showText && <span>Exit</span>}
</button>
);
};
+110
View File
@@ -0,0 +1,110 @@
import { getApiUrl, getSecretKey } from './config';
export interface SessionMetadata {
description: string;
message_count: number;
total_tokens: number | null;
}
export interface Session {
id: string;
path: string;
modified: string;
metadata: SessionMetadata;
}
export interface SessionsResponse {
sessions: Session[];
}
export interface SessionMessage {
role: 'user' | 'assistant';
created: number;
content: {
type: string;
text: string;
}[];
}
export interface SessionDetails {
session_id: string;
metadata: SessionMetadata;
messages: SessionMessage[];
}
/**
* Generate a session ID in the format yyyymmdd_hhmmss
*/
export function generateSessionId(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
return `${year}${month}${day}_${hours}${minutes}${seconds}`;
}
/**
* Fetches all available sessions from the API
* @returns Promise with sessions data
*/
export async function fetchSessions(): Promise<SessionsResponse> {
try {
const response = await fetch(getApiUrl('/sessions'), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch sessions: ${response.status} ${response.statusText}`);
}
// 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 !== ''
);
// order sessions by 'modified' date descending
sessions.sort(
(a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
);
return { sessions };
} catch (error) {
console.error('Error fetching sessions:', error);
throw error;
}
}
/**
* Fetches details for a specific session
* @param sessionId The ID of the session to fetch
* @returns Promise with session details
*/
export async function fetchSessionDetails(sessionId: string): Promise<SessionDetails> {
try {
const response = await fetch(getApiUrl(`/sessions/${sessionId}`), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Secret-Key': getSecretKey(),
},
});
if (!response.ok) {
throw new Error(`Failed to fetch session details: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error(`Error fetching session details for ${sessionId}:`, error);
throw error;
}
}
View File