Session manager (#4648)
Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
@@ -15,7 +15,6 @@ import { ExtensionInstallModal } from './components/ExtensionInstallModal';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import { GoosehintsModal } from './components/GoosehintsModal';
|
||||
import AnnouncementModal from './components/AnnouncementModal';
|
||||
import { generateSessionId } from './sessions';
|
||||
import ProviderGuard from './components/ProviderGuard';
|
||||
|
||||
import { ChatType } from './types/chat';
|
||||
@@ -321,7 +320,7 @@ export function AppInner() {
|
||||
const [_searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [chat, setChat] = useState<ChatType>({
|
||||
sessionId: generateSessionId(),
|
||||
sessionId: '',
|
||||
title: 'Pair Chat',
|
||||
messages: [],
|
||||
messageHistoryIndex: 0,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import type { Options as ClientOptions, TDataShape, Client } from './client';
|
||||
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionHistoryData, GetSessionHistoryResponses, GetSessionHistoryErrors, StatusData, StatusResponses } from './types.gen';
|
||||
import type { AddSubRecipesData, AddSubRecipesResponses, AddSubRecipesErrors, ExtendPromptData, ExtendPromptResponses, ExtendPromptErrors, ResumeAgentData, ResumeAgentResponses, ResumeAgentErrors, UpdateSessionConfigData, UpdateSessionConfigResponses, UpdateSessionConfigErrors, StartAgentData, StartAgentResponses, StartAgentErrors, GetToolsData, GetToolsResponses, GetToolsErrors, UpdateAgentProviderData, UpdateAgentProviderResponses, UpdateAgentProviderErrors, UpdateRouterToolSelectorData, UpdateRouterToolSelectorResponses, UpdateRouterToolSelectorErrors, ReadAllConfigData, ReadAllConfigResponses, BackupConfigData, BackupConfigResponses, BackupConfigErrors, CreateCustomProviderData, CreateCustomProviderResponses, CreateCustomProviderErrors, RemoveCustomProviderData, RemoveCustomProviderResponses, RemoveCustomProviderErrors, GetExtensionsData, GetExtensionsResponses, GetExtensionsErrors, AddExtensionData, AddExtensionResponses, AddExtensionErrors, RemoveExtensionData, RemoveExtensionResponses, RemoveExtensionErrors, InitConfigData, InitConfigResponses, InitConfigErrors, UpsertPermissionsData, UpsertPermissionsResponses, UpsertPermissionsErrors, ProvidersData, ProvidersResponses, GetProviderModelsData, GetProviderModelsResponses, GetProviderModelsErrors, ReadConfigData, ReadConfigResponses, ReadConfigErrors, RecoverConfigData, RecoverConfigResponses, RecoverConfigErrors, RemoveConfigData, RemoveConfigResponses, RemoveConfigErrors, UpsertConfigData, UpsertConfigResponses, UpsertConfigErrors, ValidateConfigData, ValidateConfigResponses, ValidateConfigErrors, ConfirmPermissionData, ConfirmPermissionResponses, ConfirmPermissionErrors, ManageContextData, ManageContextResponses, ManageContextErrors, StartOpenrouterSetupData, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponses, CreateRecipeData, CreateRecipeResponses, CreateRecipeErrors, DecodeRecipeData, DecodeRecipeResponses, DecodeRecipeErrors, DeleteRecipeData, DeleteRecipeResponses, DeleteRecipeErrors, EncodeRecipeData, EncodeRecipeResponses, EncodeRecipeErrors, ListRecipesData, ListRecipesResponses, ListRecipesErrors, ScanRecipeData, ScanRecipeResponses, CreateScheduleData, CreateScheduleResponses, CreateScheduleErrors, DeleteScheduleData, DeleteScheduleResponses, DeleteScheduleErrors, ListSchedulesData, ListSchedulesResponses, ListSchedulesErrors, UpdateScheduleData, UpdateScheduleResponses, UpdateScheduleErrors, InspectRunningJobData, InspectRunningJobResponses, InspectRunningJobErrors, KillRunningJobData, KillRunningJobResponses, PauseScheduleData, PauseScheduleResponses, PauseScheduleErrors, RunNowHandlerData, RunNowHandlerResponses, RunNowHandlerErrors, SessionsHandlerData, SessionsHandlerResponses, SessionsHandlerErrors, UnpauseScheduleData, UnpauseScheduleResponses, UnpauseScheduleErrors, ListSessionsData, ListSessionsResponses, ListSessionsErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, StatusData, StatusResponses } from './types.gen';
|
||||
import { client as _heyApiClient } from './client.gen';
|
||||
|
||||
export type Options<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
|
||||
@@ -424,13 +424,38 @@ export const listSessions = <ThrowOnError extends boolean = false>(options?: Opt
|
||||
});
|
||||
};
|
||||
|
||||
export const getSessionHistory = <ThrowOnError extends boolean = false>(options: Options<GetSessionHistoryData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).get<GetSessionHistoryResponses, GetSessionHistoryErrors, ThrowOnError>({
|
||||
export const getSessionInsights = <ThrowOnError extends boolean = false>(options?: Options<GetSessionInsightsData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).get<GetSessionInsightsResponses, GetSessionInsightsErrors, ThrowOnError>({
|
||||
url: '/sessions/insights',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const deleteSession = <ThrowOnError extends boolean = false>(options: Options<DeleteSessionData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).delete<DeleteSessionResponses, DeleteSessionErrors, ThrowOnError>({
|
||||
url: '/sessions/{session_id}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const getSession = <ThrowOnError extends boolean = false>(options: Options<GetSessionData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).get<GetSessionResponses, GetSessionErrors, ThrowOnError>({
|
||||
url: '/sessions/{session_id}',
|
||||
...options
|
||||
});
|
||||
};
|
||||
|
||||
export const updateSessionDescription = <ThrowOnError extends boolean = false>(options: Options<UpdateSessionDescriptionData, ThrowOnError>) => {
|
||||
return (options.client ?? _heyApiClient).put<UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ThrowOnError>({
|
||||
url: '/sessions/{session_id}/description',
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const status = <ThrowOnError extends boolean = false>(options?: Options<StatusData, ThrowOnError>) => {
|
||||
return (options?.client ?? _heyApiClient).get<StatusResponses, unknown, ThrowOnError>({
|
||||
url: '/status',
|
||||
|
||||
+141
-76
@@ -103,6 +103,8 @@ export type ContextManageResponse = {
|
||||
tokenCounts: Array<number>;
|
||||
};
|
||||
|
||||
export type Conversation = Array<Message>;
|
||||
|
||||
export type CreateCustomProviderRequest = {
|
||||
api_key: string;
|
||||
api_url: string;
|
||||
@@ -698,6 +700,25 @@ export type ScheduledJob = {
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type Session = {
|
||||
accumulated_input_tokens?: number | null;
|
||||
accumulated_output_tokens?: number | null;
|
||||
accumulated_total_tokens?: number | null;
|
||||
conversation?: Conversation | null;
|
||||
created_at: string;
|
||||
description: string;
|
||||
extension_data: ExtensionData;
|
||||
id: string;
|
||||
input_tokens?: number | null;
|
||||
message_count: number;
|
||||
output_tokens?: number | null;
|
||||
recipe?: Recipe | null;
|
||||
schedule_id?: string | null;
|
||||
total_tokens?: number | null;
|
||||
updated_at: string;
|
||||
working_dir: string;
|
||||
};
|
||||
|
||||
export type SessionConfigRequest = {
|
||||
response?: Response | null;
|
||||
session_id: string;
|
||||
@@ -718,78 +739,22 @@ export type SessionDisplayInfo = {
|
||||
workingDir: string;
|
||||
};
|
||||
|
||||
export type SessionHistoryResponse = {
|
||||
export type SessionInsights = {
|
||||
/**
|
||||
* List of messages in the session conversation
|
||||
* Total number of sessions
|
||||
*/
|
||||
messages: Array<Message>;
|
||||
metadata: SessionMetadata;
|
||||
totalSessions: number;
|
||||
/**
|
||||
* Unique identifier for the session
|
||||
* Total tokens used across all sessions
|
||||
*/
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type SessionInfo = {
|
||||
id: string;
|
||||
metadata: SessionMetadata;
|
||||
modified: string;
|
||||
path: string;
|
||||
totalTokens: number;
|
||||
};
|
||||
|
||||
export type SessionListResponse = {
|
||||
/**
|
||||
* List of available session information objects
|
||||
*/
|
||||
sessions: Array<SessionInfo>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Metadata for a session, stored as the first line in the session file
|
||||
*/
|
||||
export type SessionMetadata = {
|
||||
/**
|
||||
* The number of input tokens used in the session. Accumulated across all messages.
|
||||
*/
|
||||
accumulated_input_tokens?: number | null;
|
||||
/**
|
||||
* The number of output tokens used in the session. Accumulated across all messages.
|
||||
*/
|
||||
accumulated_output_tokens?: number | null;
|
||||
/**
|
||||
* The total number of tokens used in the session. Accumulated across all messages (useful for tracking cost over an entire session).
|
||||
*/
|
||||
accumulated_total_tokens?: number | null;
|
||||
/**
|
||||
* A short description of the session, typically 3 words or less
|
||||
*/
|
||||
description: string;
|
||||
extension_data?: ExtensionData;
|
||||
/**
|
||||
* The number of input tokens used in the session. Retrieved from the provider's last usage.
|
||||
*/
|
||||
input_tokens?: number | null;
|
||||
/**
|
||||
* Number of messages in the session
|
||||
*/
|
||||
message_count: number;
|
||||
/**
|
||||
* The number of output tokens used in the session. Retrieved from the provider's last usage.
|
||||
*/
|
||||
output_tokens?: number | null;
|
||||
recipe?: Recipe | null;
|
||||
/**
|
||||
* ID of the schedule that triggered this session, if any
|
||||
*/
|
||||
schedule_id?: string | null;
|
||||
/**
|
||||
* The total number of tokens used in the session. Retrieved from the provider's last usage.
|
||||
*/
|
||||
total_tokens?: number | null;
|
||||
/**
|
||||
* Working directory for the session
|
||||
*/
|
||||
working_dir: string;
|
||||
sessions: Array<Session>;
|
||||
};
|
||||
|
||||
export type SessionsQuery = {
|
||||
@@ -812,12 +777,6 @@ export type StartAgentRequest = {
|
||||
working_dir: string;
|
||||
};
|
||||
|
||||
export type StartAgentResponse = {
|
||||
messages: Array<Message>;
|
||||
metadata: SessionMetadata;
|
||||
session_id: string;
|
||||
};
|
||||
|
||||
export type SubRecipe = {
|
||||
description?: string | null;
|
||||
name: string;
|
||||
@@ -930,6 +889,13 @@ export type UpdateScheduleRequest = {
|
||||
cron: string;
|
||||
};
|
||||
|
||||
export type UpdateSessionDescriptionRequest = {
|
||||
/**
|
||||
* Updated description (name) for the session (max 200 characters)
|
||||
*/
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type UpsertConfigQuery = {
|
||||
is_secret: boolean;
|
||||
key: string;
|
||||
@@ -1020,7 +986,7 @@ export type ResumeAgentResponses = {
|
||||
/**
|
||||
* Agent started successfully
|
||||
*/
|
||||
200: StartAgentResponse;
|
||||
200: Session;
|
||||
};
|
||||
|
||||
export type ResumeAgentResponse = ResumeAgentResponses[keyof ResumeAgentResponses];
|
||||
@@ -1082,10 +1048,10 @@ export type StartAgentResponses = {
|
||||
/**
|
||||
* Agent started successfully
|
||||
*/
|
||||
200: StartAgentResponse;
|
||||
200: Session;
|
||||
};
|
||||
|
||||
export type StartAgentResponse2 = StartAgentResponses[keyof StartAgentResponses];
|
||||
export type StartAgentResponse = StartAgentResponses[keyof StartAgentResponses];
|
||||
|
||||
export type GetToolsData = {
|
||||
body?: never;
|
||||
@@ -2144,7 +2110,34 @@ export type ListSessionsResponses = {
|
||||
|
||||
export type ListSessionsResponse = ListSessionsResponses[keyof ListSessionsResponses];
|
||||
|
||||
export type GetSessionHistoryData = {
|
||||
export type GetSessionInsightsData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: '/sessions/insights';
|
||||
};
|
||||
|
||||
export type GetSessionInsightsErrors = {
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type GetSessionInsightsResponses = {
|
||||
/**
|
||||
* Session insights retrieved successfully
|
||||
*/
|
||||
200: SessionInsights;
|
||||
};
|
||||
|
||||
export type GetSessionInsightsResponse = GetSessionInsightsResponses[keyof GetSessionInsightsResponses];
|
||||
|
||||
export type DeleteSessionData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
@@ -2156,7 +2149,7 @@ export type GetSessionHistoryData = {
|
||||
url: '/sessions/{session_id}';
|
||||
};
|
||||
|
||||
export type GetSessionHistoryErrors = {
|
||||
export type DeleteSessionErrors = {
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
@@ -2171,14 +2164,86 @@ export type GetSessionHistoryErrors = {
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type GetSessionHistoryResponses = {
|
||||
export type DeleteSessionResponses = {
|
||||
/**
|
||||
* Session deleted successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetSessionData = {
|
||||
body?: never;
|
||||
path: {
|
||||
/**
|
||||
* Unique identifier for the session
|
||||
*/
|
||||
session_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/sessions/{session_id}';
|
||||
};
|
||||
|
||||
export type GetSessionErrors = {
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Session not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type GetSessionResponses = {
|
||||
/**
|
||||
* Session history retrieved successfully
|
||||
*/
|
||||
200: SessionHistoryResponse;
|
||||
200: Session;
|
||||
};
|
||||
|
||||
export type GetSessionHistoryResponse = GetSessionHistoryResponses[keyof GetSessionHistoryResponses];
|
||||
export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses];
|
||||
|
||||
export type UpdateSessionDescriptionData = {
|
||||
body: UpdateSessionDescriptionRequest;
|
||||
path: {
|
||||
/**
|
||||
* Unique identifier for the session
|
||||
*/
|
||||
session_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: '/sessions/{session_id}/description';
|
||||
};
|
||||
|
||||
export type UpdateSessionDescriptionErrors = {
|
||||
/**
|
||||
* Bad request - Description too long (max 200 characters)
|
||||
*/
|
||||
400: unknown;
|
||||
/**
|
||||
* Unauthorized - Invalid or missing API key
|
||||
*/
|
||||
401: unknown;
|
||||
/**
|
||||
* Session not found
|
||||
*/
|
||||
404: unknown;
|
||||
/**
|
||||
* Internal server error
|
||||
*/
|
||||
500: unknown;
|
||||
};
|
||||
|
||||
export type UpdateSessionDescriptionResponses = {
|
||||
/**
|
||||
* Session description updated successfully
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type StatusData = {
|
||||
body?: never;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { generateSessionId } from '../sessions';
|
||||
import { Recipe } from '../recipe';
|
||||
import { useDraftContext } from './DraftContext';
|
||||
|
||||
@@ -54,16 +53,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
||||
};
|
||||
|
||||
const resetChat = () => {
|
||||
const newSessionId = generateSessionId();
|
||||
setChat({
|
||||
sessionId: newSessionId,
|
||||
sessionId: '',
|
||||
title: DEFAULT_CHAT_TITLE,
|
||||
messages: [],
|
||||
messageHistoryIndex: 0,
|
||||
recipeConfig: null, // Clear recipe when resetting chat
|
||||
recipeParameters: null, // Clear when resetting chat
|
||||
recipeConfig: null,
|
||||
recipeParameters: null,
|
||||
});
|
||||
// Clear draft when resetting chat
|
||||
clearDraft();
|
||||
};
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ export const checkServerStatus = async (client: Client): Promise<boolean> => {
|
||||
try {
|
||||
await status({ client, throwOnError: true });
|
||||
return true;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
log.error('failure to connect, will retry', error);
|
||||
if (attempt === maxAttempts) {
|
||||
log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { useConfig } from '../components/ConfigContext';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { initializeSystem } from '../utils/providerUtils';
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
initConfig,
|
||||
Message as ApiMessage,
|
||||
readAllConfig,
|
||||
Recipe,
|
||||
recoverConfig,
|
||||
resumeAgent,
|
||||
startAgent,
|
||||
validateConfig,
|
||||
Recipe,
|
||||
} from '../api';
|
||||
import { COST_TRACKING_ENABLED } from '../updates';
|
||||
import { convertApiMessageToFrontendMessage } from '../components/context_management';
|
||||
@@ -72,19 +72,17 @@ export function useAgent(): UseAgentReturn {
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
const agentSessionInfo = agentResponse.data;
|
||||
const sessionMetadata = agentSessionInfo.metadata;
|
||||
let chat: ChatType = {
|
||||
sessionId: agentSessionInfo.session_id,
|
||||
title: sessionMetadata.recipe?.title || sessionMetadata.description,
|
||||
const agentSession = agentResponse.data;
|
||||
const messages = agentSession.conversation || [];
|
||||
return {
|
||||
sessionId: agentSession.id,
|
||||
title: agentSession.recipe?.title || agentSession.description,
|
||||
messageHistoryIndex: 0,
|
||||
messages: agentSessionInfo.messages.map((message: ApiMessage) =>
|
||||
messages: messages?.map((message: ApiMessage) =>
|
||||
convertApiMessageToFrontendMessage(message)
|
||||
),
|
||||
recipeConfig: sessionMetadata.recipe,
|
||||
recipeConfig: agentSession.recipe,
|
||||
};
|
||||
|
||||
return chat;
|
||||
}
|
||||
|
||||
if (initPromiseRef.current) {
|
||||
@@ -121,11 +119,11 @@ export function useAgent(): UseAgentReturn {
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
const agentSessionInfo = agentResponse.data;
|
||||
if (!agentSessionInfo) {
|
||||
const agentSession = agentResponse.data;
|
||||
if (!agentSession) {
|
||||
throw Error('Failed to get session info');
|
||||
}
|
||||
setSessionId(agentSessionInfo.session_id);
|
||||
setSessionId(agentSession.id);
|
||||
|
||||
agentWaitingMessage('Agent is loading config');
|
||||
|
||||
@@ -139,7 +137,7 @@ export function useAgent(): UseAgentReturn {
|
||||
}
|
||||
|
||||
agentWaitingMessage('Extensions are loading');
|
||||
await initializeSystem(agentSessionInfo.session_id, provider as string, model as string, {
|
||||
await initializeSystem(agentSession.id, provider as string, model as string, {
|
||||
getExtensions,
|
||||
addExtension,
|
||||
setIsExtensionsLoading: initContext.setIsExtensionsLoading,
|
||||
@@ -153,15 +151,15 @@ export function useAgent(): UseAgentReturn {
|
||||
}
|
||||
}
|
||||
|
||||
const sessionMetadata = agentSessionInfo.metadata;
|
||||
const messages = agentSession.conversation || [];
|
||||
let initChat: ChatType = {
|
||||
sessionId: agentSessionInfo.session_id,
|
||||
title: sessionMetadata.recipe?.title || sessionMetadata.description,
|
||||
sessionId: agentSession.id,
|
||||
title: agentSession.recipe?.title || agentSession.description,
|
||||
messageHistoryIndex: 0,
|
||||
messages: agentSessionInfo.messages.map((message: ApiMessage) =>
|
||||
messages: messages.map((message: ApiMessage) =>
|
||||
convertApiMessageToFrontendMessage(message)
|
||||
),
|
||||
recipeConfig: sessionMetadata.recipe,
|
||||
recipeConfig: agentSession.recipe,
|
||||
};
|
||||
|
||||
setAgentState(AgentState.INITIALIZED);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { getApiUrl } from '../config';
|
||||
import { useMessageStream } from './useMessageStream';
|
||||
import { fetchSessionDetails } from '../sessions';
|
||||
import { LocalMessageStorage } from '../utils/localMessageStorage';
|
||||
import {
|
||||
Message,
|
||||
@@ -16,6 +15,7 @@ import {
|
||||
} from '../types/message';
|
||||
import { ChatType } from '../types/chat';
|
||||
import { ChatState } from '../types/chatState';
|
||||
import { getSession } from '../api';
|
||||
|
||||
// Helper function to determine if a message is a user message
|
||||
const isUserMessage = (message: Message): boolean => {
|
||||
@@ -85,7 +85,7 @@ export const useChatEngine = ({
|
||||
handleInputChange: _handleInputChange,
|
||||
updateMessageStreamBody,
|
||||
notifications,
|
||||
sessionMetadata,
|
||||
session,
|
||||
setError,
|
||||
} = useMessageStream({
|
||||
api: getApiUrl('/reply'),
|
||||
@@ -199,14 +199,17 @@ export const useChatEngine = ({
|
||||
setChat((prevChat: ChatType) => ({ ...prevChat, messages }));
|
||||
}, [messages, setChat]);
|
||||
|
||||
// Fetch session metadata to get token count
|
||||
useEffect(() => {
|
||||
const fetchSessionTokens = async () => {
|
||||
try {
|
||||
const sessionDetails = await fetchSessionDetails(chat.sessionId);
|
||||
setSessionTokenCount(sessionDetails.metadata.total_tokens || 0);
|
||||
setSessionInputTokens(sessionDetails.metadata.accumulated_input_tokens || 0);
|
||||
setSessionOutputTokens(sessionDetails.metadata.accumulated_output_tokens || 0);
|
||||
const response = await getSession<true>({
|
||||
path: { session_id: chat.sessionId },
|
||||
throwOnError: true,
|
||||
});
|
||||
const sessionDetails = response.data;
|
||||
setSessionTokenCount(sessionDetails.total_tokens || 0);
|
||||
setSessionInputTokens(sessionDetails.accumulated_input_tokens || 0);
|
||||
setSessionOutputTokens(sessionDetails.accumulated_output_tokens || 0);
|
||||
} catch (err) {
|
||||
console.error('Error fetching session token count:', err);
|
||||
}
|
||||
@@ -219,13 +222,13 @@ export const useChatEngine = ({
|
||||
|
||||
// Update token counts when sessionMetadata changes from the message stream
|
||||
useEffect(() => {
|
||||
console.log('Session metadata received:', sessionMetadata);
|
||||
if (sessionMetadata) {
|
||||
setSessionTokenCount(sessionMetadata.total_tokens || 0);
|
||||
setSessionInputTokens(sessionMetadata.accumulated_input_tokens || 0);
|
||||
setSessionOutputTokens(sessionMetadata.accumulated_output_tokens || 0);
|
||||
console.log('Session metadata received:', session);
|
||||
if (session) {
|
||||
setSessionTokenCount(session.total_tokens || 0);
|
||||
setSessionInputTokens(session.accumulated_input_tokens || 0);
|
||||
setSessionOutputTokens(session.accumulated_output_tokens || 0);
|
||||
}
|
||||
}, [sessionMetadata]);
|
||||
}, [session]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -473,7 +476,7 @@ export const useChatEngine = ({
|
||||
|
||||
// Stream utilities
|
||||
updateMessageStreamBody,
|
||||
sessionMetadata,
|
||||
sessionMetadata: session,
|
||||
|
||||
// Utilities
|
||||
isUserMessage,
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useModelAndProvider } from '../components/ModelAndProviderContext';
|
||||
import { getCostForModel } from '../utils/costDatabase';
|
||||
import { SessionMetadata } from '../api';
|
||||
import { Session } from '../api';
|
||||
|
||||
interface UseCostTrackingProps {
|
||||
sessionInputTokens: number;
|
||||
sessionOutputTokens: number;
|
||||
localInputTokens: number;
|
||||
localOutputTokens: number;
|
||||
sessionMetadata?: SessionMetadata | null;
|
||||
session?: Session | null;
|
||||
}
|
||||
|
||||
export const useCostTracking = ({
|
||||
@@ -16,7 +16,7 @@ export const useCostTracking = ({
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
sessionMetadata,
|
||||
session,
|
||||
}: UseCostTrackingProps) => {
|
||||
const [sessionCosts, setSessionCosts] = useState<{
|
||||
[key: string]: {
|
||||
@@ -79,7 +79,7 @@ export const useCostTracking = ({
|
||||
sessionOutputTokens,
|
||||
localInputTokens,
|
||||
localOutputTokens,
|
||||
sessionMetadata,
|
||||
session,
|
||||
]);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useId, useReducer, useRef, useState } from 'react';
|
||||
import useSWR from 'swr';
|
||||
import { createUserMessage, hasCompletedToolCalls, Message, Role } from '../types/message';
|
||||
import { getSessionHistory, SessionMetadata } from '../api';
|
||||
import { getSession, Session } from '../api';
|
||||
import { ChatState } from '../types/chatState';
|
||||
|
||||
let messageIdCounter = 0;
|
||||
@@ -152,8 +152,8 @@ export interface UseMessageStreamHelpers {
|
||||
/** Current model info from the backend */
|
||||
currentModelInfo: { model: string; mode: string } | null;
|
||||
|
||||
/** Session metadata including token counts */
|
||||
sessionMetadata: SessionMetadata | null;
|
||||
/** Session including token counts */
|
||||
session: Session | null;
|
||||
|
||||
/** Clear error state */
|
||||
setError: (error: Error | undefined) => void;
|
||||
@@ -188,7 +188,7 @@ export function useMessageStream({
|
||||
const [currentModelInfo, setCurrentModelInfo] = useState<{ model: string; mode: string } | null>(
|
||||
null
|
||||
);
|
||||
const [sessionMetadata, setSessionMetadata] = useState<SessionMetadata | null>(null);
|
||||
const [session, setSession] = useState<Session | null>(null);
|
||||
|
||||
// expose a way to update the body so we can update the session id when CLE occurs
|
||||
const updateMessageStreamBody = useCallback((newBody: object) => {
|
||||
@@ -333,26 +333,21 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
case 'Finish': {
|
||||
// Call onFinish with the last message if available
|
||||
if (onFinish && currentMessages.length > 0) {
|
||||
const lastMessage = currentMessages[currentMessages.length - 1];
|
||||
onFinish(lastMessage, parsedEvent.reason);
|
||||
}
|
||||
|
||||
// Fetch updated session metadata with token counts
|
||||
const sessionId = (extraMetadataRef.current.body as Record<string, unknown>)
|
||||
?.session_id as string;
|
||||
if (sessionId) {
|
||||
try {
|
||||
const sessionResponse = await getSessionHistory({
|
||||
path: { session_id: sessionId },
|
||||
});
|
||||
const sessionResponse = await getSession({
|
||||
path: { session_id: sessionId },
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (sessionResponse.data?.metadata) {
|
||||
setSessionMetadata(sessionResponse.data?.metadata);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch session metadata:', error);
|
||||
if (sessionResponse.data) {
|
||||
setSession(sessionResponse.data);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -631,7 +626,7 @@ export function useMessageStream({
|
||||
updateMessageStreamBody,
|
||||
notifications,
|
||||
currentModelInfo,
|
||||
sessionMetadata,
|
||||
session: session,
|
||||
setError,
|
||||
};
|
||||
}
|
||||
|
||||
+6
-175
@@ -1,185 +1,16 @@
|
||||
import { Message } from './types/message';
|
||||
import {
|
||||
getSessionHistory,
|
||||
listSessions,
|
||||
SessionInfo,
|
||||
Message as ApiMessage,
|
||||
SessionMetadata,
|
||||
} from './api';
|
||||
import { convertApiMessageToFrontendMessage } from './components/context_management';
|
||||
import { getApiUrl } from './config';
|
||||
import { Session } from './api';
|
||||
|
||||
// Helper function to ensure working directory is set
|
||||
export function ensureWorkingDir(metadata: Partial<SessionMetadata>): SessionMetadata {
|
||||
return {
|
||||
description: metadata.description || '',
|
||||
message_count: metadata.message_count || 0,
|
||||
total_tokens: metadata.total_tokens || null,
|
||||
working_dir: metadata.working_dir || process.env.HOME || '',
|
||||
accumulated_input_tokens: metadata.accumulated_input_tokens || null,
|
||||
accumulated_output_tokens: metadata.accumulated_output_tokens || null,
|
||||
accumulated_total_tokens: metadata.accumulated_total_tokens || null,
|
||||
};
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
path: string;
|
||||
modified: string;
|
||||
metadata: SessionMetadata;
|
||||
}
|
||||
|
||||
export interface SessionDetails {
|
||||
sessionId: string;
|
||||
metadata: SessionMetadata;
|
||||
messages: Message[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/**
|
||||
* Fetches all available sessions from the API
|
||||
* @returns Promise with an array of Session objects
|
||||
*/
|
||||
export async function fetchSessions(): Promise<Session[]> {
|
||||
const response = await listSessions<true>();
|
||||
|
||||
// Check if the response has the expected structure
|
||||
if (response && response.data && response.data.sessions) {
|
||||
// Since the API returns SessionInfo, we need to convert to Session
|
||||
const sessions = response.data.sessions
|
||||
.filter(
|
||||
(sessionInfo: SessionInfo) => sessionInfo.metadata && sessionInfo.metadata.message_count > 0
|
||||
)
|
||||
.map(
|
||||
(sessionInfo: SessionInfo): Session => ({
|
||||
id: sessionInfo.id,
|
||||
path: sessionInfo.path,
|
||||
modified: sessionInfo.modified,
|
||||
metadata: ensureWorkingDir(sessionInfo.metadata),
|
||||
})
|
||||
);
|
||||
|
||||
// order sessions by 'modified' date descending
|
||||
sessions.sort(
|
||||
(a: Session, b: Session) => new Date(b.modified).getTime() - new Date(a.modified).getTime()
|
||||
);
|
||||
|
||||
return sessions;
|
||||
} else {
|
||||
throw new Error('Unexpected response format from listSessions');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
const response = await getSessionHistory<true>({
|
||||
path: { session_id: sessionId },
|
||||
});
|
||||
|
||||
try {
|
||||
// Convert the SessionHistoryResponse to a SessionDetails object
|
||||
return {
|
||||
sessionId: response.data.sessionId,
|
||||
metadata: ensureWorkingDir(response.data.metadata),
|
||||
messages: response.data.messages.map((message: ApiMessage) =>
|
||||
convertApiMessageToFrontendMessage(message)
|
||||
), // slight diffs between backend and frontend Message obj
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error fetching session details for ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the metadata for a specific session
|
||||
* @param sessionId The ID of the session to update
|
||||
* @param description The new description (name) for the session
|
||||
* @returns Promise that resolves when the update is complete
|
||||
*/
|
||||
export async function updateSessionMetadata(sessionId: string, description: string): Promise<void> {
|
||||
const url = getApiUrl(`/sessions/${sessionId}/metadata`);
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': secretKey,
|
||||
},
|
||||
body: JSON.stringify({ description }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to update session metadata: ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a session. Currently, this opens a new window with the session loaded.
|
||||
*/
|
||||
export function resumeSession(session: SessionDetails | Session) {
|
||||
const resumedSessionId = 'sessionId' in session ? session.sessionId : session.id;
|
||||
console.log('Launching session in new window:', resumedSessionId);
|
||||
const workingDir = session.metadata?.working_dir;
|
||||
export function resumeSession(session: Session) {
|
||||
console.log('Launching session in new window:', session.description || session.id);
|
||||
const workingDir = session.working_dir;
|
||||
if (!workingDir) {
|
||||
throw new Error('Cannot resume session: working directory is missing in session metadata');
|
||||
throw new Error('Cannot resume session: working directory is missing in session');
|
||||
}
|
||||
|
||||
window.electron.createChatWindow(
|
||||
undefined, // query
|
||||
workingDir,
|
||||
undefined, // version
|
||||
resumedSessionId
|
||||
session.id
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a specific session
|
||||
* @param sessionId The ID of the session to delete
|
||||
* @returns Promise that resolves when the deletion is complete
|
||||
*/
|
||||
export async function deleteSession(sessionId: string): Promise<void> {
|
||||
try {
|
||||
const url = getApiUrl(`/sessions/${sessionId}/delete`);
|
||||
const secretKey = await window.electron.getSecretKey();
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'X-Secret-Key': secretKey,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`Failed to delete session: ${response.statusText} - ${errorText}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error deleting session ${sessionId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Session } from '../sessions';
|
||||
import { Session } from '../api';
|
||||
|
||||
export interface DateGroup {
|
||||
label: string;
|
||||
@@ -16,7 +16,7 @@ export function groupSessionsByDate(sessions: Session[]): DateGroup[] {
|
||||
const groups: { [key: string]: DateGroup } = {};
|
||||
|
||||
sessions.forEach((session) => {
|
||||
const sessionDate = new Date(session.modified);
|
||||
const sessionDate = new Date(session.updated_at);
|
||||
const sessionDateStart = new Date(sessionDate);
|
||||
sessionDateStart.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user