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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user