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
@@ -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;