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:
+78
-9
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user