Session manager (#4648)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-09-26 14:56:45 -04:00
committed by GitHub
parent d0aa14a1ea
commit dc292883b7
61 changed files with 2895 additions and 5572 deletions
+1 -1
View File
@@ -265,7 +265,7 @@ function BaseChatContent({
sessionOutputTokens,
localInputTokens,
localOutputTokens,
sessionMetadata,
session: sessionMetadata,
});
useEffect(() => {
@@ -94,8 +94,7 @@ describe('ExtensionInstallModal', () => {
expect(screen.getAllByRole('button')).toHaveLength(3);
});
it("should handle i-ching-mcp-server as allowed command", async () => {
it('should handle i-ching-mcp-server as allowed command', async () => {
mockElectron.getAllowedExtensions.mockResolvedValue([]);
render(<ExtensionInstallModal addExtension={mockAddExtension} />);
@@ -103,13 +102,16 @@ describe('ExtensionInstallModal', () => {
const eventHandler = getAddExtensionEventHandler();
await act(async () => {
await eventHandler({}, "goose://extension?cmd=i-ching-mcp-server&id=i-ching&name=I%20Ching&description=I%20Ching%20divination");
await eventHandler(
{},
'goose://extension?cmd=i-ching-mcp-server&id=i-ching&name=I%20Ching&description=I%20Ching%20divination'
);
});
expect(screen.getByRole("dialog")).toBeInTheDocument();
expect(screen.getByText("Confirm Extension Installation")).toBeInTheDocument();
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(screen.getByText('Confirm Extension Installation')).toBeInTheDocument();
expect(screen.getByText(/I Ching extension/)).toBeInTheDocument();
expect(screen.getAllByRole("button")).toHaveLength(3);
expect(screen.getAllByRole('button')).toHaveLength(3);
});
it('should handle blocked extension', async () => {
mockElectron.getAllowedExtensions.mockResolvedValue(['uvx allowed-package']);
@@ -1,356 +0,0 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { Search, ChevronDown, Folder, Loader2 } from 'lucide-react';
import { fetchSessions, type Session } from '../../sessions';
import { Input } from '../ui/input';
import {
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarGroup,
SidebarGroupLabel,
SidebarGroupContent,
} from '../ui/sidebar';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
import { useTextAnimator } from '../../hooks/use-text-animator';
interface SessionsSectionProps {
onSelectSession: (sessionId: string) => void;
refreshTrigger?: number;
}
interface GroupedSessions {
today: Session[];
yesterday: Session[];
older: { [key: string]: Session[] };
}
export const SessionsSection: React.FC<SessionsSectionProps> = ({
onSelectSession,
refreshTrigger,
}) => {
const [sessions, setSessions] = useState<Session[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [groupedSessions, setGroupedSessions] = useState<GroupedSessions>({
today: [],
yesterday: [],
older: {},
});
const [sessionsWithDescriptions, setSessionsWithDescriptions] = useState<Set<string>>(new Set());
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const groupSessions = useCallback((sessionsToGroup: Session[]) => {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
const grouped: GroupedSessions = {
today: [],
yesterday: [],
older: {},
};
sessionsToGroup.forEach((session) => {
const sessionDate = new Date(session.modified);
const sessionDateOnly = new Date(
sessionDate.getFullYear(),
sessionDate.getMonth(),
sessionDate.getDate()
);
if (sessionDateOnly.getTime() === today.getTime()) {
grouped.today.push(session);
} else if (sessionDateOnly.getTime() === yesterday.getTime()) {
grouped.yesterday.push(session);
} else {
const dateKey = sessionDateOnly.toISOString().split('T')[0];
if (!grouped.older[dateKey]) {
grouped.older[dateKey] = [];
}
grouped.older[dateKey].push(session);
}
});
// Sort older sessions by date (newest first)
const sortedOlder: { [key: string]: Session[] } = {};
Object.keys(grouped.older)
.sort()
.reverse()
.forEach((key) => {
sortedOlder[key] = grouped.older[key];
});
grouped.older = sortedOlder;
setGroupedSessions(grouped);
}, []);
const loadSessions = useCallback(async () => {
try {
const sessions = await fetchSessions();
setSessions(sessions);
groupSessions(sessions);
} catch (err) {
console.error('Failed to load sessions:', err);
setSessions([]);
setGroupedSessions({ today: [], yesterday: [], older: {} });
}
}, [groupSessions]);
// Debounced refresh function
const debouncedRefresh = useCallback(() => {
console.log('SessionsSection: Debounced refresh triggered');
// Clear any existing timeout
if (refreshTimeoutRef.current) {
window.clearTimeout(refreshTimeoutRef.current);
}
// Set new timeout - reduced to 200ms for faster response
refreshTimeoutRef.current = setTimeout(() => {
console.log('SessionsSection: Executing debounced refresh');
loadSessions();
refreshTimeoutRef.current = null;
}, 200);
}, [loadSessions]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (refreshTimeoutRef.current) {
window.clearTimeout(refreshTimeoutRef.current);
}
};
}, []);
useEffect(() => {
console.log('SessionsSection: Initial load');
loadSessions();
}, [loadSessions]);
// Add effect to refresh sessions when refreshTrigger changes
useEffect(() => {
if (refreshTrigger) {
console.log('SessionsSection: Refresh trigger changed, triggering refresh');
debouncedRefresh();
}
}, [refreshTrigger, debouncedRefresh]);
// Add effect to listen for session creation events
useEffect(() => {
const handleSessionCreated = () => {
console.log('SessionsSection: Session created event received');
debouncedRefresh();
};
const handleMessageStreamFinish = () => {
console.log('SessionsSection: Message stream finished event received');
// Always refresh when message stream finishes
debouncedRefresh();
};
// Listen for custom events that indicate a session was created
window.addEventListener('session-created', handleSessionCreated);
// Also listen for message stream finish events
window.addEventListener('message-stream-finished', handleMessageStreamFinish);
return () => {
window.removeEventListener('session-created', handleSessionCreated);
window.removeEventListener('message-stream-finished', handleMessageStreamFinish);
};
}, [debouncedRefresh]);
useEffect(() => {
if (searchTerm) {
const filtered = sessions.filter((session) =>
(session.metadata.description || session.id)
.toLowerCase()
.includes(searchTerm.toLowerCase())
);
groupSessions(filtered);
} else {
groupSessions(sessions);
}
}, [searchTerm, sessions, groupSessions]);
// Component for individual session items with loading and animation states
const SessionItem = ({ session }: { session: Session }) => {
const hasDescription =
session.metadata.description && session.metadata.description.trim() !== '';
const isNewSession = session.id.match(/^\d{8}_\d{6}$/);
const messageCount = session.metadata.message_count || 0;
// Show loading for new sessions with few messages and no description
// Only show loading for sessions created in the last 5 minutes
const sessionDate = new Date(session.modified);
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
const isRecentSession = sessionDate > fiveMinutesAgo;
const shouldShowLoading =
!hasDescription && isNewSession && messageCount <= 2 && isRecentSession;
const [isAnimating, setIsAnimating] = useState(false);
// Use text animator only for sessions that need animation
const descriptionRef = useTextAnimator({
text: isAnimating ? session.metadata.description : '',
});
// Track when description becomes available and trigger animation
useEffect(() => {
if (hasDescription && !sessionsWithDescriptions.has(session.id)) {
setSessionsWithDescriptions((prev) => new Set(prev).add(session.id));
// Only animate for new sessions that were showing loading
if (shouldShowLoading) {
setIsAnimating(true);
}
}
}, [hasDescription, session.id, shouldShowLoading]);
const handleClick = () => {
console.log('SessionItem: Clicked on session:', session.id);
onSelectSession(session.id);
};
return (
<SidebarMenuItem key={session.id}>
<SidebarMenuButton
onClick={handleClick}
className="cursor-pointer w-56 transition-all duration-300 ease-in-out hover:bg-background-medium hover:shadow-sm rounded-xl text-text-muted hover:text-text-default h-fit flex items-start transform hover:scale-[1.02] active:scale-[0.98]"
>
<div className="flex flex-col w-full">
<div className="text-sm w-48 truncate mb-1 px-1 text-ellipsis text-text-default flex items-center gap-2">
{shouldShowLoading ? (
<div className="flex items-center gap-2 animate-in fade-in duration-300">
<Loader2 className="size-3 animate-spin text-text-default" />
<span className="text-text-default animate-pulse">Generating description...</span>
</div>
) : (
<span
ref={isAnimating ? descriptionRef : undefined}
className={`transition-all duration-300 ${isAnimating ? 'animate-in fade-in duration-300' : ''}`}
>
{hasDescription ? session.metadata.description : `Session ${session.id}`}
</span>
)}
</div>
<div className="text-xs w-48 truncate px-1 flex items-center gap-2 text-ellipsis transition-colors duration-300">
<Folder className="size-4 transition-transform duration-300 group-hover:scale-110" />
<span className="transition-all duration-300">{session.metadata.working_dir}</span>
</div>
</div>
</SidebarMenuButton>
</SidebarMenuItem>
);
};
const renderSessionGroup = (sessions: Session[], title: string, index: number) => {
if (sessions.length === 0) return null;
const isFirstTwoGroups = index < 2;
return (
<Collapsible defaultOpen={isFirstTwoGroups} className="group/collapsible">
<SidebarGroup>
<CollapsibleTrigger className="w-full">
<SidebarGroupLabel className="flex cursor-pointer items-center justify-between text-text-default hover:text-text-default h-12 pl-3 transition-all duration-200 rounded-lg">
<div className="flex min-w-0 items-center">
<span className="opacity-100 transition-all duration-300 text-xs font-medium">
{title}
</span>
</div>
<ChevronDown className="size-4 text-text-muted flex-shrink-0 opacity-100 transition-all duration-300 ease-in-out group-data-[state=open]/collapsible:rotate-180" />
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden transition-all duration-300 ease-in-out">
<SidebarGroupContent>
<SidebarMenu className="mb-2 space-y-1">
{sessions.map((session, sessionIndex) => (
<div
key={session.id}
className="animate-in slide-in-from-left-2 fade-in duration-300"
style={{
animationDelay: `${sessionIndex * 50}ms`,
animationFillMode: 'both',
}}
>
<SessionItem session={session} />
</div>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
};
return (
<Collapsible defaultOpen={false} className="group/collapsible rounded-xl">
<SidebarGroup className="px-1">
<CollapsibleTrigger className="w-full">
<SidebarGroupLabel className="flex cursor-pointer items-center py-6 justify-between text-text-default px-4 transition-all duration-200 hover:bg-background-default rounded-lg">
<div className="flex min-w-0 items-center">
<span className="text-sm">Sessions</span>
</div>
<ChevronDown className="size-4 text-text-muted flex-shrink-0 opacity-100 transition-all duration-300 ease-in-out group-data-[state=open]/collapsible:rotate-180" />
</SidebarGroupLabel>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden transition-all duration-300 ease-in-out">
<SidebarGroupContent>
{/* Search Input */}
<div className="p-1 pb-2 animate-in slide-in-from-top-2 fade-in duration-300">
<div className="relative flex flex-row items-center gap-2">
<Search className="absolute top-2.5 left-2.5 size-4 text-muted-foreground" />
<Input
type="search"
placeholder="Search sessions..."
className="pl-8 transition-all duration-200 focus:ring-2 focus:ring-borderProminent"
value={searchTerm}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value)
}
/>
</div>
</div>
{/* Sessions Groups */}
<div className="space-y-2">
{(() => {
let groupIndex = 0;
const groups = [
{ sessions: groupedSessions.today, title: 'Today' },
{ sessions: groupedSessions.yesterday, title: 'Yesterday' },
...Object.entries(groupedSessions.older).map(([date, sessions]) => ({
sessions,
title: new Date(date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
}),
})),
];
return groups.map(({ sessions, title }) => {
if (sessions.length === 0) return null;
const currentIndex = groupIndex++;
return (
<div
key={title}
className="animate-in slide-in-from-left-2 fade-in duration-300"
style={{
animationDelay: `${currentIndex * 100}ms`,
animationFillMode: 'both',
}}
>
{renderSessionGroup(sessions, title, currentIndex)}
</div>
);
});
})()}
</div>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
);
};
@@ -3,7 +3,6 @@ import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
import BackButton from '../ui/BackButton';
import { Card } from '../ui/card';
import { fetchSessionDetails, SessionDetails } from '../../sessions';
import {
getScheduleSessions,
runScheduleNow,
@@ -21,6 +20,7 @@ import { toastError, toastSuccess } from '../../toasts';
import { Loader2, Pause, Play, Edit, Square, Eye } from 'lucide-react';
import cronstrue from 'cronstrue';
import { formatToLocalDateWithTimezone } from '../../utils/date';
import { getSession, Session } from '../../api';
interface ScheduleSessionMeta {
id: string;
@@ -146,7 +146,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
// Track if we explicitly killed a job to distinguish from natural completion
const [jobWasKilled, setJobWasKilled] = useState(false);
const [selectedSessionDetails, setSelectedSessionDetails] = useState<SessionDetails | null>(null);
const [selectedSessionDetails, setSelectedSessionDetails] = useState<Session | null>(null);
const [isLoadingSessionDetails, setIsLoadingSessionDetails] = useState(false);
const [sessionDetailsError, setSessionDetailsError] = useState<string | null>(null);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
@@ -430,8 +430,11 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
setSessionDetailsError(null);
setSelectedSessionDetails(null);
try {
const details = await fetchSessionDetails(sessionId);
setSelectedSessionDetails(details);
const response = await getSession<true>({
path: { session_id: sessionId },
throwOnError: true,
});
setSelectedSessionDetails(response.data);
} catch (err) {
console.error(`Failed to load session details for ${sessionId}:`, err);
const errorMsg = err instanceof Error ? err.message : 'Failed to load session details.';
@@ -459,7 +462,7 @@ const ScheduleDetailView: React.FC<ScheduleDetailViewProps> = ({ scheduleId, onN
setSelectedSessionDetails(null);
setSessionDetailsError(null);
}}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails?.sessionId)}
onRetry={() => loadAndShowSessionDetails(selectedSessionDetails?.id)}
showActionButtons={true}
/>
);
@@ -11,7 +11,7 @@ import {
LoaderCircle,
AlertCircle,
} from 'lucide-react';
import { resumeSession, type SessionDetails } from '../../sessions';
import { resumeSession } from '../../sessions';
import { Button } from '../ui/button';
import { toast } from 'react-toastify';
import { MainPanelLayout } from '../Layout/MainPanelLayout';
@@ -32,6 +32,8 @@ import { ContextManagerProvider } from '../context_management/ContextManager';
import { Message } from '../../types/message';
import BackButton from '../ui/BackButton';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/Tooltip';
import { Session } from '../../api';
import { convertApiMessageToFrontendMessage } from '../context_management';
// Helper function to determine if a message is a user message (same as useChatEngine)
const isUserMessage = (message: Message): boolean => {
@@ -46,7 +48,7 @@ const filterMessagesForDisplay = (messages: Message[]): Message[] => {
};
interface SessionHistoryViewProps {
session: SessionDetails;
session: Session;
isLoading: boolean;
error: string | null;
onBack: () => void;
@@ -73,14 +75,12 @@ const SessionHeader: React.FC<{
);
};
// Session messages component that uses the same rendering as BaseChat
const SessionMessages: React.FC<{
messages: Message[];
isLoading: boolean;
error: string | null;
onRetry: () => void;
}> = ({ messages, isLoading, error, onRetry }) => {
// Filter messages for display (same as BaseChat)
const filteredMessages = filterMessagesForDisplay(messages);
return (
@@ -153,6 +153,8 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
const [isCopied, setIsCopied] = useState(false);
const [canShare, setCanShare] = useState(false);
const messages = (session.conversation || []).map(convertApiMessageToFrontendMessage);
useEffect(() => {
const savedSessionConfig = localStorage.getItem('session_sharing_config');
if (savedSessionConfig) {
@@ -183,10 +185,10 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
const shareToken = await createSharedSession(
config.baseUrl,
session.metadata.working_dir,
session.messages,
session.metadata.description || 'Shared Session',
session.metadata.total_tokens || 0
session.working_dir,
messages,
session.description || 'Shared Session',
session.total_tokens || 0
);
const shareableLink = `goose://sessions/${shareToken}`;
@@ -270,32 +272,32 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
<div className="flex-1 flex flex-col min-h-0 px-8">
<SessionHeader
onBack={onBack}
title={session.metadata.description || 'Session Details'}
title={session.description || 'Session Details'}
actionButtons={!isLoading ? actionButtons : null}
>
<div className="flex flex-col">
{!isLoading && session.messages.length > 0 ? (
{!isLoading ? (
<>
<div className="flex items-center text-text-muted text-sm space-x-5 font-mono">
<span className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{formatMessageTimestamp(session.messages[0]?.created)}
{formatMessageTimestamp(messages[0]?.created)}
</span>
<span className="flex items-center">
<MessageSquareText className="w-4 h-4 mr-1" />
{session.metadata.message_count}
{session.message_count}
</span>
{session.metadata.total_tokens !== null && (
{session.total_tokens !== null && (
<span className="flex items-center">
<Target className="w-4 h-4 mr-1" />
{(session.metadata.total_tokens || 0).toLocaleString()}
{(session.total_tokens || 0).toLocaleString()}
</span>
)}
</div>
<div className="flex items-center text-text-muted text-sm mt-1 font-mono">
<span className="flex items-center">
<Folder className="w-4 h-4 mr-1" />
{session.metadata.working_dir}
{session.working_dir}
</span>
</div>
</>
@@ -309,7 +311,7 @@ const SessionHistoryView: React.FC<SessionHistoryViewProps> = ({
</SessionHeader>
<SessionMessages
messages={session.messages}
messages={messages}
isLoading={isLoading}
error={error}
onRetry={onRetry}
@@ -1,7 +1,7 @@
import React from 'react';
import { Session } from '../../sessions';
import { Card } from '../ui/card';
import { formatDate } from '../../utils/date';
import { Session } from '../../api';
interface SessionItemProps {
session: Session;
@@ -12,11 +12,11 @@ const SessionItem: React.FC<SessionItemProps> = ({ session, extraActions }) => {
return (
<Card className="p-4 mb-2 hover:bg-accent/50 cursor-pointer flex justify-between items-center">
<div>
<div className="font-medium">{session.metadata.description || `Session ${session.id}`}</div>
<div className="font-medium">{session.description || `Session ${session.id}`}</div>
<div className="text-sm text-muted-foreground">
{formatDate(session.modified)} {session.metadata.message_count} messages
{formatDate(session.updated_at)} {session.message_count} messages
</div>
<div className="text-sm text-muted-foreground">{session.metadata.working_dir}</div>
<div className="text-sm text-muted-foreground">{session.working_dir}</div>
</div>
{extraActions && <div>{extraActions}</div>}
</Card>
@@ -8,7 +8,6 @@ import {
Edit2,
Trash2,
} from 'lucide-react';
import { fetchSessions, updateSessionMetadata, deleteSession, type Session } from '../../sessions';
import { Card } from '../ui/card';
import { Button } from '../ui/button';
import { ScrollArea } from '../ui/scroll-area';
@@ -21,6 +20,7 @@ import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils';
import { Skeleton } from '../ui/skeleton';
import { toast } from 'react-toastify';
import { ConfirmationModal } from '../ui/ConfirmationModal';
import { deleteSession, listSessions, Session, updateSessionDescription } from '../../api';
interface EditSessionModalProps {
session: Session | null;
@@ -37,7 +37,7 @@ const EditSessionModal = React.memo<EditSessionModalProps>(
useEffect(() => {
if (session && isOpen) {
setDescription(session.metadata.description || session.id);
setDescription(session.description || session.id);
} else if (!isOpen) {
// Reset state when modal closes
setDescription('');
@@ -49,14 +49,18 @@ const EditSessionModal = React.memo<EditSessionModalProps>(
if (!session || disabled) return;
const trimmedDescription = description.trim();
if (trimmedDescription === session.metadata.description) {
if (trimmedDescription === session.description) {
onClose();
return;
}
setIsUpdating(true);
try {
await updateSessionMetadata(session.id, trimmedDescription);
await updateSessionDescription({
path: { session_id: session.id },
body: { description: trimmedDescription },
throwOnError: true,
});
await onSave(session.id, trimmedDescription);
// Close modal, then show success toast on a timeout to let the UI update complete.
@@ -68,8 +72,7 @@ const EditSessionModal = React.memo<EditSessionModalProps>(
const errorMessage = error instanceof Error ? error.message : 'Unknown error occurred';
console.error('Failed to update session description:', errorMessage);
toast.error(`Failed to update session description: ${errorMessage}`);
// Reset to original description on error
setDescription(session.metadata.description || session.id);
setDescription(session.description || session.id);
} finally {
setIsUpdating(false);
}
@@ -213,7 +216,8 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
setShowContent(false);
setError(null);
try {
const sessions = await fetchSessions();
const resp = await listSessions<true>({ throwOnError: true });
const sessions = resp.data.sessions;
// Use startTransition to make state updates non-blocking
startTransition(() => {
setSessions(sessions);
@@ -291,20 +295,20 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
startTransition(() => {
const searchTerm = caseSensitive ? debouncedSearchTerm : debouncedSearchTerm.toLowerCase();
const filtered = sessions.filter((session) => {
const description = session.metadata.description || session.id;
const path = session.path;
const workingDir = session.metadata.working_dir;
const description = session.description || session.id;
const workingDir = session.working_dir;
const sessionId = session.id;
if (caseSensitive) {
return (
description.includes(searchTerm) ||
path.includes(searchTerm) ||
sessionId.includes(searchTerm) ||
workingDir.includes(searchTerm)
);
} else {
return (
description.toLowerCase().includes(searchTerm) ||
path.toLowerCase().includes(searchTerm) ||
sessionId.toLowerCase().includes(searchTerm) ||
workingDir.toLowerCase().includes(searchTerm)
);
}
@@ -355,11 +359,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => {
// Update state immediately for optimistic UI
setSessions((prevSessions) =>
prevSessions.map((s) =>
s.id === sessionId
? { ...s, metadata: { ...s.metadata, description: newDescription } }
: s
)
prevSessions.map((s) => (s.id === sessionId ? { ...s, description: newDescription } : s))
);
}, []);
@@ -378,18 +378,21 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
setShowDeleteConfirmation(false);
const sessionToDeleteId = sessionToDelete.id;
const sessionName = sessionToDelete.metadata.description || sessionToDelete.id;
const sessionName = sessionToDelete.description || sessionToDelete.id;
setSessionToDelete(null);
try {
await deleteSession(sessionToDeleteId);
await deleteSession({
path: { session_id: sessionToDeleteId },
throwOnError: true,
});
toast.success('Session deleted successfully');
loadSessions();
} catch (error) {
console.error('Error deleting session:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to delete session "${sessionName}": ${errorMessage}`);
}
await loadSessions();
}, [sessionToDelete, loadSessions]);
const handleCancelDelete = useCallback(() => {
@@ -451,16 +454,16 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex-1">
<h3 className="text-base mb-1 pr-16 break-words">
{session.metadata.description || session.id}
{session.description || session.id}
</h3>
<div className="flex items-center text-text-muted text-xs mb-1">
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
<span>{formatMessageTimestamp(Date.parse(session.updated_at) / 1000)}</span>
</div>
<div className="flex items-center text-text-muted text-xs mb-1">
<Folder className="w-3 h-3 mr-1 flex-shrink-0" />
<span className="truncate">{session.metadata.working_dir}</span>
<span className="truncate">{session.working_dir}</span>
</div>
</div>
@@ -468,14 +471,12 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex items-center space-x-3 text-xs text-text-muted">
<div className="flex items-center">
<MessageSquareText className="w-3 h-3 mr-1" />
<span className="font-mono">{session.metadata.message_count}</span>
<span className="font-mono">{session.message_count}</span>
</div>
{session.metadata.total_tokens !== null && (
{session.total_tokens !== null && (
<div className="flex items-center">
<Target className="w-3 h-3 mr-1" />
<span className="font-mono">
{(session.metadata.total_tokens || 0).toLocaleString()}
</span>
<span className="font-mono">{(session.total_tokens || 0).toLocaleString()}</span>
</div>
)}
</div>
@@ -675,7 +676,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<ConfirmationModal
isOpen={showDeleteConfirmation}
title="Delete Session"
message={`Are you sure you want to delete the session "${sessionToDelete?.metadata.description || sessionToDelete?.id}"? This action cannot be undone.`}
message={`Are you sure you want to delete the session "${sessionToDelete?.description || sessionToDelete?.id}"? This action cannot be undone.`}
confirmLabel="Delete Session"
cancelLabel="Cancel"
confirmVariant="destructive"
@@ -72,7 +72,7 @@ export const SessionHeaderCard: React.FC<SessionHeaderCardProps> = ({ onBack, ch
/**
* Props for the SessionMessages component
*/
export interface SessionMessagesProps {
interface SessionMessagesProps {
messages: Message[];
isLoading: boolean;
error: string | null;
@@ -1,23 +1,21 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardDescription } from '../ui/card';
import { getApiUrl } from '../../config';
import { Greeting } from '../common/Greeting';
import { fetchSessions, type Session, resumeSession } from '../../sessions';
import { useNavigate } from 'react-router-dom';
import { Button } from '../ui/button';
import { ChatSmart } from '../icons/';
import { Goose } from '../icons/Goose';
import { Skeleton } from '../ui/skeleton';
interface SessionInsightsType {
totalSessions: number;
mostActiveDirs: [string, number][];
avgSessionDuration: number;
totalTokens: number;
}
import {
getSessionInsights,
listSessions,
Session,
SessionInsights as ApiSessionInsights,
} from '../../api';
import { resumeSession } from '../../sessions';
export function SessionInsights() {
const [insights, setInsights] = useState<SessionInsightsType | null>(null);
const [insights, setInsights] = useState<ApiSessionInsights | null>(null);
const [error, setError] = useState<string | null>(null);
const [recentSessions, setRecentSessions] = useState<Session[]>([]);
const [isLoading, setIsLoading] = useState(true);
@@ -29,31 +27,14 @@ export function SessionInsights() {
const loadInsights = async () => {
try {
const response = await fetch(getApiUrl('/sessions/insights'), {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-Secret-Key': await window.electron.getSecretKey(),
},
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch insights: ${response.status} ${errorText}`);
}
const data = await response.json();
setInsights(data);
// Clear any previous error when insights load successfully
const response = await getSessionInsights({ throwOnError: true });
setInsights(response.data);
setError(null);
} catch (error) {
console.error('Failed to load insights:', error);
setError(error instanceof Error ? error.message : 'Failed to load insights');
// Set fallback insights data so the UI can still render
setInsights({
totalSessions: 0,
mostActiveDirs: [],
avgSessionDuration: 0,
totalTokens: 0,
});
} finally {
@@ -63,10 +44,8 @@ export function SessionInsights() {
const loadRecentSessions = async () => {
try {
const sessions = await fetchSessions();
setRecentSessions(sessions.slice(0, 3));
} catch (error) {
console.error('Failed to load recent sessions:', error);
const response = await listSessions<true>({ throwOnError: true });
setRecentSessions(response.data.sessions.slice(0, 3));
} finally {
setIsLoadingSessions(false);
}
@@ -85,6 +64,7 @@ export function SessionInsights() {
mostActiveDirs: [],
avgSessionDuration: 0,
totalTokens: 0,
recentActivity: [],
};
}
// If we already have insights, just make sure loading is false
@@ -155,16 +135,6 @@ export function SessionInsights() {
</CardContent>
</Card>
{/* Average Duration Card Skeleton */}
{/*<Card className="w-full py-6 px-6 border-none rounded-2xl bg-background-default">*/}
{/* <CardContent className="flex flex-col justify-end h-full p-0">*/}
{/* <div className="flex flex-col justify-end">*/}
{/* <Skeleton className="h-10 w-20 mb-1" />*/}
{/* <span className="text-xs text-text-muted">Avg. chat length</span>*/}
{/* </div>*/}
{/* </CardContent>*/}
{/*</Card>*/}
{/* Total Tokens Card Skeleton */}
<Card className="w-full py-6 px-6 border-none rounded-2xl bg-background-default">
<CardContent className="flex flex-col justify-end h-full p-0">
@@ -363,11 +333,11 @@ export function SessionInsights() {
<div className="flex items-center space-x-2">
<ChatSmart className="h-4 w-4 text-text-muted" />
<span className="truncate max-w-[300px]">
{session.metadata.description || session.id}
{session.description || session.id}
</span>
</div>
<span className="text-text-muted font-mono font-light">
{formatDateOnly(session.modified)}
{formatDateOnly(session.updated_at)}
</span>
</div>
))
@@ -1,17 +1,16 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, ViewOptions } from '../../utils/navigationUtils';
import { fetchSessionDetails, type SessionDetails } from '../../sessions';
import SessionListView from './SessionListView';
import SessionHistoryView from './SessionHistoryView';
import { toastError } from '../../toasts';
import { useLocation } from 'react-router-dom';
import { getSession, Session } from '../../api';
interface SessionsViewProps {
setView: (view: View, viewOptions?: ViewOptions) => void;
}
const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const [selectedSession, setSelectedSession] = useState<SessionDetails | null>(null);
const [selectedSession, setSelectedSession] = useState<Session | null>(null);
const [showSessionHistory, setShowSessionHistory] = useState(false);
const [isLoadingSession, setIsLoadingSession] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -23,21 +22,17 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
setError(null);
setShowSessionHistory(true);
try {
const sessionDetails = await fetchSessionDetails(sessionId);
setSelectedSession(sessionDetails);
const response = await getSession<true>({
path: { session_id: sessionId },
throwOnError: true,
});
setSelectedSession(response.data);
} 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);
setShowSessionHistory(false);
const errorMessage = err instanceof Error ? err.message : String(err);
toastError({
title: 'Failed to load session. The file may be corrupted.',
msg: 'Please try again later.',
traceback: errorMessage,
});
} finally {
setIsLoadingSession(false);
setInitialSessionId(null);
@@ -68,7 +63,7 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
const handleRetryLoadSession = () => {
if (selectedSession) {
loadSessionDetails(selectedSession.sessionId);
loadSessionDetails(selectedSession.id);
}
};
@@ -78,14 +73,15 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
<SessionHistoryView
session={
selectedSession || {
sessionId: initialSessionId || '',
messages: [],
metadata: {
description: 'Loading...',
working_dir: '',
message_count: 0,
total_tokens: 0,
},
id: initialSessionId || '',
conversation: [],
description: 'Loading...',
working_dir: '',
message_count: 0,
total_tokens: 0,
created_at: '',
updated_at: '',
extension_data: {},
}
}
isLoading={isLoadingSession}
@@ -97,7 +93,7 @@ const SessionsView: React.FC<SessionsViewProps> = ({ setView }) => {
<SessionListView
setView={setView}
onSelectSession={handleSelectSession}
selectedSessionId={selectedSession?.sessionId ?? null}
selectedSessionId={selectedSession?.id ?? null}
/>
);
};