diff --git a/ui/desktop/src/components/sessions/SessionHistoryView.tsx b/ui/desktop/src/components/sessions/SessionHistoryView.tsx index 73b9f83693b1..82cd8fe25d7e 100644 --- a/ui/desktop/src/components/sessions/SessionHistoryView.tsx +++ b/ui/desktop/src/components/sessions/SessionHistoryView.tsx @@ -186,7 +186,7 @@ const SessionHistoryView: React.FC = ({ session.metadata.working_dir, session.messages, session.metadata.description || 'Shared Session', - session.metadata.total_tokens + session.metadata.total_tokens || 0 ); const shareableLink = `goose://sessions/${shareToken}`; @@ -288,7 +288,7 @@ const SessionHistoryView: React.FC = ({ {session.metadata.total_tokens !== null && ( - {session.metadata.total_tokens.toLocaleString()} + {(session.metadata.total_tokens || 0).toLocaleString()} )} diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index d3c2ef1b25e5..a108903d9549 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -158,449 +158,457 @@ interface SessionListViewProps { selectedSessionId?: string | null; } -const SessionListView: React.FC = React.memo(({ onSelectSession, selectedSessionId }) => { - const [sessions, setSessions] = useState([]); - const [filteredSessions, setFilteredSessions] = useState([]); - const [dateGroups, setDateGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showSkeleton, setShowSkeleton] = useState(true); - const [showContent, setShowContent] = useState(false); - const [isInitialLoad, setIsInitialLoad] = useState(true); - const [error, setError] = useState(null); - const [searchResults, setSearchResults] = useState<{ - count: number; - currentIndex: number; - } | null>(null); - - // Edit modal state - const [showEditModal, setShowEditModal] = useState(false); - const [editingSession, setEditingSession] = useState(null); - - // Search state for debouncing - const [searchTerm, setSearchTerm] = useState(''); - const [caseSensitive, setCaseSensitive] = useState(false); - const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce - - const containerRef = useRef(null); - - // Track session to element ref - const sessionRefs = useRef>({}); - const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => { - if (element) { - sessionRefs.current[itemId] = element; - } else { - delete sessionRefs.current[itemId]; - } - }; - - const loadSessions = useCallback(async () => { - setIsLoading(true); - setShowSkeleton(true); - setShowContent(false); - setError(null); - try { - const sessions = await fetchSessions(); - // Use startTransition to make state updates non-blocking - startTransition(() => { - setSessions(sessions); - setFilteredSessions(sessions); - }); - } catch (err) { - console.error('Failed to load sessions:', err); - setError('Failed to load sessions. Please try again later.'); - setSessions([]); - setFilteredSessions([]); - } finally { - setIsLoading(false); - } - }, []); +const SessionListView: React.FC = React.memo( + ({ onSelectSession, selectedSessionId }) => { + const [sessions, setSessions] = useState([]); + const [filteredSessions, setFilteredSessions] = useState([]); + const [dateGroups, setDateGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showSkeleton, setShowSkeleton] = useState(true); + const [showContent, setShowContent] = useState(false); + const [isInitialLoad, setIsInitialLoad] = useState(true); + const [error, setError] = useState(null); + const [searchResults, setSearchResults] = useState<{ + count: number; + currentIndex: number; + } | null>(null); + + // Edit modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editingSession, setEditingSession] = useState(null); + + // Search state for debouncing + const [searchTerm, setSearchTerm] = useState(''); + const [caseSensitive, setCaseSensitive] = useState(false); + const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce + + const containerRef = useRef(null); + + // Track session to element ref + const sessionRefs = useRef>({}); + const setSessionRefs = (itemId: string, element: HTMLDivElement | null) => { + if (element) { + sessionRefs.current[itemId] = element; + } else { + delete sessionRefs.current[itemId]; + } + }; - useEffect(() => { - loadSessions(); - }, [loadSessions]); + const loadSessions = useCallback(async () => { + setIsLoading(true); + setShowSkeleton(true); + setShowContent(false); + setError(null); + try { + const sessions = await fetchSessions(); + // Use startTransition to make state updates non-blocking + startTransition(() => { + setSessions(sessions); + setFilteredSessions(sessions); + }); + } catch (err) { + console.error('Failed to load sessions:', err); + setError('Failed to load sessions. Please try again later.'); + setSessions([]); + setFilteredSessions([]); + } finally { + setIsLoading(false); + } + }, []); - // Timing logic to prevent flicker between skeleton and content on initial load - useEffect(() => { - if (!isLoading && showSkeleton) { - setShowSkeleton(false); - // Use startTransition for non-blocking content show - startTransition(() => { - setTimeout(() => { - setShowContent(true); - if (isInitialLoad) { - setIsInitialLoad(false); - } - }, 10); - }); - } - return () => void 0; - }, [isLoading, showSkeleton, isInitialLoad]); - - // Memoize date groups calculation to prevent unnecessary recalculations - const memoizedDateGroups = useMemo(() => { - if (filteredSessions.length > 0) { - return groupSessionsByDate(filteredSessions); - } - return []; - }, [filteredSessions]); - - // Update date groups when filtered sessions change - useEffect(() => { - startTransition(() => { - setDateGroups(memoizedDateGroups); - }); - }, [memoizedDateGroups]); + useEffect(() => { + loadSessions(); + }, [loadSessions]); - // Scroll to the selected session when returning from session history view - useEffect(() => { - if (selectedSessionId) { - const element = sessionRefs.current[selectedSessionId]; - if (element) { - element.scrollIntoView({ - block: "center" + // Timing logic to prevent flicker between skeleton and content on initial load + useEffect(() => { + if (!isLoading && showSkeleton) { + setShowSkeleton(false); + // Use startTransition for non-blocking content show + startTransition(() => { + setTimeout(() => { + setShowContent(true); + if (isInitialLoad) { + setIsInitialLoad(false); + } + }, 10); }); } - } - }, [selectedSessionId, sessions]); + return () => void 0; + }, [isLoading, showSkeleton, isInitialLoad]); - // Debounced search effect - performs actual filtering - useEffect(() => { - if (!debouncedSearchTerm) { + // Memoize date groups calculation to prevent unnecessary recalculations + const memoizedDateGroups = useMemo(() => { + if (filteredSessions.length > 0) { + return groupSessionsByDate(filteredSessions); + } + return []; + }, [filteredSessions]); + + // Update date groups when filtered sessions change + useEffect(() => { startTransition(() => { - setFilteredSessions(sessions); - setSearchResults(null); + setDateGroups(memoizedDateGroups); }); - return; - } - - // Use startTransition to make search non-blocking - 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; - - if (caseSensitive) { - return ( - description.includes(searchTerm) || - path.includes(searchTerm) || - workingDir.includes(searchTerm) - ); - } else { - return ( - description.toLowerCase().includes(searchTerm) || - path.toLowerCase().includes(searchTerm) || - workingDir.toLowerCase().includes(searchTerm) - ); + }, [memoizedDateGroups]); + + // Scroll to the selected session when returning from session history view + useEffect(() => { + if (selectedSessionId) { + const element = sessionRefs.current[selectedSessionId]; + if (element) { + element.scrollIntoView({ + block: 'center', + }); } + } + }, [selectedSessionId, sessions]); + + // Debounced search effect - performs actual filtering + useEffect(() => { + if (!debouncedSearchTerm) { + startTransition(() => { + setFilteredSessions(sessions); + setSearchResults(null); + }); + return; + } + + // Use startTransition to make search non-blocking + 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; + + if (caseSensitive) { + return ( + description.includes(searchTerm) || + path.includes(searchTerm) || + workingDir.includes(searchTerm) + ); + } else { + return ( + description.toLowerCase().includes(searchTerm) || + path.toLowerCase().includes(searchTerm) || + workingDir.toLowerCase().includes(searchTerm) + ); + } + }); + + setFilteredSessions(filtered); + setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null); }); + }, [debouncedSearchTerm, caseSensitive, sessions]); - setFilteredSessions(filtered); - setSearchResults(filtered.length > 0 ? { count: filtered.length, currentIndex: 1 } : null); - }); - }, [debouncedSearchTerm, caseSensitive, sessions]); - - // Handle immediate search input (updates search term for debouncing) - const handleSearch = useCallback((term: string, caseSensitive: boolean) => { - setSearchTerm(term); - setCaseSensitive(caseSensitive); - }, []); - - // Handle search result navigation - const handleSearchNavigation = (direction: 'next' | 'prev') => { - if (!searchResults || filteredSessions.length === 0) return; - - let newIndex: number; - if (direction === 'next') { - newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; - } else { - newIndex = - searchResults.currentIndex === 1 ? filteredSessions.length : searchResults.currentIndex - 1; - } - - setSearchResults({ ...searchResults, currentIndex: newIndex }); - - // Find the SearchView's container element - const searchContainer = - containerRef.current?.querySelector('.search-container'); - if (searchContainer?._searchHighlighter) { - // Update the current match in the highlighter - searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true); - } - }; - - // Handle modal close - const handleModalClose = useCallback(() => { - setShowEditModal(false); - setEditingSession(null); - }, []); - - 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 - ) - ); - }, []); - - const handleEditSession = useCallback((session: Session) => { - setEditingSession(session); - setShowEditModal(true); - }, []); - - const SessionItem = React.memo(function SessionItem({ - session, - onEditClick, - }: { - session: Session; - onEditClick: (session: Session) => void; - }) { - const handleEditClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); // Prevent card click - onEditClick(session); - }, - [onEditClick, session] - ); + // Handle immediate search input (updates search term for debouncing) + const handleSearch = useCallback((term: string, caseSensitive: boolean) => { + setSearchTerm(term); + setCaseSensitive(caseSensitive); + }, []); - const handleCardClick = useCallback(() => { - onSelectSession(session.id); - }, [session.id]); + // Handle search result navigation + const handleSearchNavigation = (direction: 'next' | 'prev') => { + if (!searchResults || filteredSessions.length === 0) return; + + let newIndex: number; + if (direction === 'next') { + newIndex = (searchResults.currentIndex % filteredSessions.length) + 1; + } else { + newIndex = + searchResults.currentIndex === 1 + ? filteredSessions.length + : searchResults.currentIndex - 1; + } - return ( - setSessionRefs(session.id, el)} - > - + setSearchResults({ ...searchResults, currentIndex: newIndex }); -
-

- {session.metadata.description || session.id} -

+ // Find the SearchView's container element + const searchContainer = + containerRef.current?.querySelector('.search-container'); + if (searchContainer?._searchHighlighter) { + // Update the current match in the highlighter + searchContainer._searchHighlighter.setCurrentMatch(newIndex - 1, true); + } + }; -
- - {formatMessageTimestamp(Date.parse(session.modified) / 1000)} -
-
- - {session.metadata.working_dir} -
-
+ // Handle modal close + const handleModalClose = useCallback(() => { + setShowEditModal(false); + setEditingSession(null); + }, []); + + 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 + ) + ); + }, []); + + const handleEditSession = useCallback((session: Session) => { + setEditingSession(session); + setShowEditModal(true); + }, []); + + const SessionItem = React.memo(function SessionItem({ + session, + onEditClick, + }: { + session: Session; + onEditClick: (session: Session) => void; + }) { + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); // Prevent card click + onEditClick(session); + }, + [onEditClick, session] + ); + + const handleCardClick = useCallback(() => { + onSelectSession(session.id); + }, [session.id]); -
-
-
- - {session.metadata.message_count} + return ( + setSessionRefs(session.id, el)} + > + + +
+

+ {session.metadata.description || session.id} +

+ +
+ + {formatMessageTimestamp(Date.parse(session.modified) / 1000)}
- {session.metadata.total_tokens !== null && ( +
+ + {session.metadata.working_dir} +
+
+ +
+
- - {session.metadata.total_tokens.toLocaleString()} + + {session.metadata.message_count}
- )} + {session.metadata.total_tokens !== null && ( +
+ + + {(session.metadata.total_tokens || 0).toLocaleString()} + +
+ )} +
-
- - ); - }); - - // Render skeleton loader for session items with variations - const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { - const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; - const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; - const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; + + ); + }); - return ( - -
- -
- - -
-
- - -
-
+ // Render skeleton loader for session items with variations + const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { + const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; + const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; + const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; -
-
-
+ return ( + +
+ +
- +
-
+
- +
-
-
- ); - }); - - SessionSkeleton.displayName = 'SessionSkeleton'; - const renderActualContent = () => { - if (error) { - return ( -
- -

Error Loading Sessions

-

{error}

- -
+
+
+
+ + +
+
+ + +
+
+
+ ); - } + }); - if (sessions.length === 0) { - return ( -
- -

No chat sessions found

-

Your chat history will appear here

-
- ); - } + SessionSkeleton.displayName = 'SessionSkeleton'; + + const renderActualContent = () => { + if (error) { + return ( +
+ +

Error Loading Sessions

+

{error}

+ +
+ ); + } - if (dateGroups.length === 0 && searchResults !== null) { + if (sessions.length === 0) { + return ( +
+ +

No chat sessions found

+

Your chat history will appear here

+
+ ); + } + + if (dateGroups.length === 0 && searchResults !== null) { + return ( +
+ +

No matching sessions found

+

Try adjusting your search terms

+
+ ); + } + + // For regular rendering in grid layout return ( -
- -

No matching sessions found

-

Try adjusting your search terms

+
+ {dateGroups.map((group) => ( +
+
+

{group.label}

+
+
+ {group.sessions.map((session) => ( + + ))} +
+
+ ))}
); - } + }; - // For regular rendering in grid layout return ( -
- {dateGroups.map((group) => ( -
-
-

{group.label}

-
-
- {group.sessions.map((session) => ( - - ))} -
-
- ))} -
- ); - }; - - return ( - <> - -
-
-
-
-

Chat history

+ <> + +
+
+
+
+

Chat history

+
+

+ View and search your past conversations with Goose. +

-

- View and search your past conversations with Goose. -

-
-
- -
- - {/* Skeleton layer - always rendered but conditionally visible */} -
+ +
+ -
- {/* Today section */} -
- -
- - - - - + {/* Skeleton layer - always rendered but conditionally visible */} +
+
+ {/* Today section */} +
+ +
+ + + + + +
-
- {/* Yesterday section */} -
- -
- - - - - - + {/* Yesterday section */} +
+ +
+ + + + + + +
-
- {/* Additional section */} -
- -
- - - + {/* Additional section */} +
+ +
+ + + +
-
- {/* Content layer - always rendered but conditionally visible */} -
- {renderActualContent()} -
- -
- + {/* Content layer - always rendered but conditionally visible */} +
+ {renderActualContent()} +
+ +
+ +
-
- - - - - ); -}); + + + + + ); + } +); SessionListView.displayName = 'SessionListView'; diff --git a/ui/desktop/src/hooks/useChatEngine.ts b/ui/desktop/src/hooks/useChatEngine.ts index 82054cdb09c5..eb01d0b6cd2c 100644 --- a/ui/desktop/src/hooks/useChatEngine.ts +++ b/ui/desktop/src/hooks/useChatEngine.ts @@ -222,9 +222,9 @@ export const useChatEngine = ({ useEffect(() => { console.log('Session metadata received:', sessionMetadata); if (sessionMetadata) { - setSessionTokenCount(sessionMetadata.totalTokens || 0); - setSessionInputTokens(sessionMetadata.accumulatedInputTokens || 0); - setSessionOutputTokens(sessionMetadata.accumulatedOutputTokens || 0); + setSessionTokenCount(sessionMetadata.total_tokens || 0); + setSessionInputTokens(sessionMetadata.accumulated_input_tokens || 0); + setSessionOutputTokens(sessionMetadata.accumulated_output_tokens || 0); } }, [sessionMetadata]); diff --git a/ui/desktop/src/hooks/useCostTracking.ts b/ui/desktop/src/hooks/useCostTracking.ts index 87d7ed379035..cf22d0e0f3cd 100644 --- a/ui/desktop/src/hooks/useCostTracking.ts +++ b/ui/desktop/src/hooks/useCostTracking.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { useModelAndProvider } from '../components/ModelAndProviderContext'; import { getCostForModel } from '../utils/costDatabase'; -import { SessionMetadata } from './useMessageStream'; +import { SessionMetadata } from '../api'; interface UseCostTrackingProps { sessionInputTokens: number; diff --git a/ui/desktop/src/hooks/useMessageStream.ts b/ui/desktop/src/hooks/useMessageStream.ts index c6e01d170caa..4fdb0eaa1755 100644 --- a/ui/desktop/src/hooks/useMessageStream.ts +++ b/ui/desktop/src/hooks/useMessageStream.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react'; import useSWR from 'swr'; import { createUserMessage, hasCompletedToolCalls, Message } from '../types/message'; -import { getSessionHistory } from '../api'; +import { getSessionHistory, SessionMetadata } from '../api'; import { ChatState } from '../types/chatState'; let messageIdCounter = 0; @@ -15,19 +15,6 @@ const TextDecoder = globalThis.TextDecoder; type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue }; -export interface SessionMetadata { - workingDir: string; - description: string; - scheduleId: string | null; - messageCount: number; - totalTokens: number | null; - inputTokens: number | null; - outputTokens: number | null; - accumulatedTotalTokens: number | null; - accumulatedInputTokens: number | null; - accumulatedOutputTokens: number | null; -} - export interface NotificationEvent { type: 'Notification'; request_id: string; @@ -369,21 +356,7 @@ export function useMessageStream({ }); if (sessionResponse.data?.metadata) { - setSessionMetadata({ - workingDir: sessionResponse.data.metadata.working_dir, - description: sessionResponse.data.metadata.description, - scheduleId: sessionResponse.data.metadata.schedule_id || null, - messageCount: sessionResponse.data.metadata.message_count, - totalTokens: sessionResponse.data.metadata.total_tokens || null, - inputTokens: sessionResponse.data.metadata.input_tokens || null, - outputTokens: sessionResponse.data.metadata.output_tokens || null, - accumulatedTotalTokens: - sessionResponse.data.metadata.accumulated_total_tokens || null, - accumulatedInputTokens: - sessionResponse.data.metadata.accumulated_input_tokens || null, - accumulatedOutputTokens: - sessionResponse.data.metadata.accumulated_output_tokens || null, - }); + setSessionMetadata(sessionResponse.data?.metadata); } } catch (error) { console.error('Failed to fetch session metadata:', error); diff --git a/ui/desktop/src/sessions.ts b/ui/desktop/src/sessions.ts index 975535e64c72..a19220945931 100644 --- a/ui/desktop/src/sessions.ts +++ b/ui/desktop/src/sessions.ts @@ -1,19 +1,14 @@ import { Message } from './types/message'; -import { getSessionHistory, listSessions, SessionInfo, Message as ApiMessage } from './api'; +import { + getSessionHistory, + listSessions, + SessionInfo, + Message as ApiMessage, + SessionMetadata, +} from './api'; import { convertApiMessageToFrontendMessage } from './components/context_management'; import { getApiUrl } from './config'; -export interface SessionMetadata { - description: string; - message_count: number; - total_tokens: number | null; - working_dir: string; // Required in type, but may be missing in old sessions - // Add the accumulated token fields from the API - accumulated_input_tokens?: number | null; - accumulated_output_tokens?: number | null; - accumulated_total_tokens?: number | null; -} - // Helper function to ensure working directory is set export function ensureWorkingDir(metadata: Partial): SessionMetadata { return {