diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index d5dd848db5f8..db13b3d9f218 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -352,6 +352,8 @@ derive_utoipa!(Icon as IconSchema); super::routes::session::get_session_insights, super::routes::session::update_session_description, super::routes::session::delete_session, + super::routes::session::export_session, + super::routes::session::import_session, super::routes::session::update_session_user_recipe_values, super::routes::schedule::create_schedule, super::routes::schedule::list_schedules, @@ -389,6 +391,7 @@ derive_utoipa!(Icon as IconSchema); super::routes::reply::ChatRequest, super::routes::context::ContextManageRequest, super::routes::context::ContextManageResponse, + super::routes::session::ImportSessionRequest, super::routes::session::SessionListResponse, super::routes::session::UpdateSessionDescriptionRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest, diff --git a/crates/goose-server/src/routes/session.rs b/crates/goose-server/src/routes/session.rs index bcd3e30e4dc7..3bea6a1251f0 100644 --- a/crates/goose-server/src/routes/session.rs +++ b/crates/goose-server/src/routes/session.rs @@ -1,4 +1,5 @@ use crate::state::AppState; +use axum::routing::post; use axum::{ extract::Path, http::StatusCode, @@ -33,6 +34,12 @@ pub struct UpdateSessionUserRecipeValuesRequest { user_recipe_values: HashMap, } +#[derive(Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ImportSessionRequest { + json: String, +} + const MAX_DESCRIPTION_LENGTH: usize = 200; #[utoipa::path( @@ -199,11 +206,63 @@ async fn delete_session(Path(session_id): Path) -> Result) -> Result, StatusCode> { + let exported = SessionManager::export_session(&session_id) + .await + .map_err(|_| StatusCode::NOT_FOUND)?; + + Ok(Json(exported)) +} + +#[utoipa::path( + post, + path = "/sessions/import", + request_body = ImportSessionRequest, + responses( + (status = 200, description = "Session imported successfully", body = Session), + (status = 401, description = "Unauthorized - Invalid or missing API key"), + (status = 400, description = "Bad request - Invalid JSON"), + (status = 500, description = "Internal server error") + ), + security( + ("api_key" = []) + ), + tag = "Session Management" +)] +async fn import_session( + Json(request): Json, +) -> Result, StatusCode> { + let session = SessionManager::import_session(&request.json) + .await + .map_err(|_| StatusCode::BAD_REQUEST)?; + + Ok(Json(session)) +} + pub fn routes(state: Arc) -> Router { Router::new() .route("/sessions", get(list_sessions)) .route("/sessions/{session_id}", get(get_session)) .route("/sessions/{session_id}", delete(delete_session)) + .route("/sessions/{session_id}/export", get(export_session)) + .route("/sessions/import", post(import_session)) .route("/sessions/insights", get(get_session_insights)) .route( "/sessions/{session_id}/description", diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 1a846af5471c..283c52f434f1 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -211,6 +211,14 @@ impl SessionManager { Self::instance().await?.get_insights().await } + pub async fn export_session(id: &str) -> Result { + Self::instance().await?.export_session(id).await + } + + pub async fn import_session(json: &str) -> Result { + Self::instance().await?.import_session(json).await + } + pub async fn maybe_update_description(id: &str, provider: Arc) -> Result<()> { let session = Self::get_session(id, true).await?; let conversation = session @@ -895,6 +903,41 @@ impl SessionStorage { total_tokens: row.1.unwrap_or(0), }) } + + async fn export_session(&self, id: &str) -> Result { + let session = self.get_session(id, true).await?; + serde_json::to_string_pretty(&session).map_err(Into::into) + } + + async fn import_session(&self, json: &str) -> Result { + let import: Session = serde_json::from_str(json)?; + + let session = self + .create_session(import.working_dir.clone(), import.description.clone()) + .await?; + + self.apply_update( + SessionUpdateBuilder::new(session.id.clone()) + .extension_data(import.extension_data) + .total_tokens(import.total_tokens) + .input_tokens(import.input_tokens) + .output_tokens(import.output_tokens) + .accumulated_total_tokens(import.accumulated_total_tokens) + .accumulated_input_tokens(import.accumulated_input_tokens) + .accumulated_output_tokens(import.accumulated_output_tokens) + .schedule_id(import.schedule_id) + .recipe(import.recipe) + .user_recipe_values(import.user_recipe_values), + ) + .await?; + + if let Some(conversation) = import.conversation { + self.replace_conversation(&session.id, &conversation) + .await?; + } + + self.get_session(&session.id, true).await + } } #[cfg(test)] @@ -997,4 +1040,80 @@ mod tests { let expected_tokens = 100 * NUM_CONCURRENT_SESSIONS * (NUM_CONCURRENT_SESSIONS - 1) / 2; assert_eq!(insights.total_tokens, expected_tokens as i64); } + + #[tokio::test] + async fn test_export_import_roundtrip() { + const DESCRIPTION: &str = "Original session"; + const TOTAL_TOKENS: i32 = 500; + const INPUT_TOKENS: i32 = 300; + const OUTPUT_TOKENS: i32 = 200; + const ACCUMULATED_TOKENS: i32 = 1000; + const USER_MESSAGE: &str = "test message"; + const ASSISTANT_MESSAGE: &str = "test response"; + + let temp_dir = TempDir::new().unwrap(); + let db_path = temp_dir.path().join("test_export.db"); + let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap()); + + let original = storage + .create_session(PathBuf::from("/tmp/test"), DESCRIPTION.to_string()) + .await + .unwrap(); + + storage + .apply_update( + SessionUpdateBuilder::new(original.id.clone()) + .total_tokens(Some(TOTAL_TOKENS)) + .input_tokens(Some(INPUT_TOKENS)) + .output_tokens(Some(OUTPUT_TOKENS)) + .accumulated_total_tokens(Some(ACCUMULATED_TOKENS)), + ) + .await + .unwrap(); + + storage + .add_message( + &original.id, + &Message { + id: None, + role: Role::User, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::text(USER_MESSAGE)], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + storage + .add_message( + &original.id, + &Message { + id: None, + role: Role::Assistant, + created: chrono::Utc::now().timestamp_millis(), + content: vec![MessageContent::text(ASSISTANT_MESSAGE)], + metadata: Default::default(), + }, + ) + .await + .unwrap(); + + let exported = storage.export_session(&original.id).await.unwrap(); + let imported = storage.import_session(&exported).await.unwrap(); + + assert_ne!(imported.id, original.id); + assert_eq!(imported.description, DESCRIPTION); + assert_eq!(imported.working_dir, PathBuf::from("/tmp/test")); + assert_eq!(imported.total_tokens, Some(TOTAL_TOKENS)); + assert_eq!(imported.input_tokens, Some(INPUT_TOKENS)); + assert_eq!(imported.output_tokens, Some(OUTPUT_TOKENS)); + assert_eq!(imported.accumulated_total_tokens, Some(ACCUMULATED_TOKENS)); + assert_eq!(imported.message_count, 2); + + let conversation = imported.conversation.unwrap(); + assert_eq!(conversation.messages().len(), 2); + assert_eq!(conversation.messages()[0].role, Role::User); + assert_eq!(conversation.messages()[1].role, Role::Assistant); + } } diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index a8398f713d3d..a3e3ae4f9d33 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -1615,6 +1615,50 @@ ] } }, + "/sessions/import": { + "post": { + "tags": [ + "Session Management" + ], + "operationId": "import_session", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Session imported successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "400": { + "description": "Bad request - Invalid JSON" + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/sessions/insights": { "get": { "tags": [ @@ -1778,6 +1822,51 @@ ] } }, + "/sessions/{session_id}/export": { + "get": { + "tags": [ + "Session Management" + ], + "operationId": "export_session", + "parameters": [ + { + "name": "session_id", + "in": "path", + "description": "Unique identifier for the session", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Session exported successfully", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + }, + "401": { + "description": "Unauthorized - Invalid or missing API key" + }, + "404": { + "description": "Session not found" + }, + "500": { + "description": "Internal server error" + } + }, + "security": [ + { + "api_key": [] + } + ] + } + }, "/sessions/{session_id}/user_recipe_values": { "put": { "tags": [ @@ -2819,6 +2908,17 @@ } } }, + "ImportSessionRequest": { + "type": "object", + "required": [ + "json" + ], + "properties": { + "json": { + "type": "string" + } + } + }, "InspectJobResponse": { "type": "object", "properties": { diff --git a/ui/desktop/src/api/sdk.gen.ts b/ui/desktop/src/api/sdk.gen.ts index 60dc95ced607..fe793bfb21e3 100644 --- a/ui/desktop/src/api/sdk.gen.ts +++ b/ui/desktop/src/api/sdk.gen.ts @@ -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, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, 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, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, 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, ParseRecipeData, ParseRecipeResponses, ParseRecipeErrors, SaveRecipeData, SaveRecipeResponses, SaveRecipeErrors, ScanRecipeData, ScanRecipeResponses, ReplyData, ReplyResponses, ReplyErrors, 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, ImportSessionData, ImportSessionResponses, ImportSessionErrors, GetSessionInsightsData, GetSessionInsightsResponses, GetSessionInsightsErrors, DeleteSessionData, DeleteSessionResponses, DeleteSessionErrors, GetSessionData, GetSessionResponses, GetSessionErrors, UpdateSessionDescriptionData, UpdateSessionDescriptionResponses, UpdateSessionDescriptionErrors, ExportSessionData, ExportSessionResponses, ExportSessionErrors, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, StatusData, StatusResponses } from './types.gen'; import { client as _heyApiClient } from './client.gen'; export type Options = ClientOptions & { @@ -457,6 +457,17 @@ export const listSessions = (options?: Opt }); }; +export const importSession = (options: Options) => { + return (options.client ?? _heyApiClient).post({ + url: '/sessions/import', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + export const getSessionInsights = (options?: Options) => { return (options?.client ?? _heyApiClient).get({ url: '/sessions/insights', @@ -489,6 +500,13 @@ export const updateSessionDescription = (o }); }; +export const exportSession = (options: Options) => { + return (options.client ?? _heyApiClient).get({ + url: '/sessions/{session_id}/export', + ...options + }); +}; + export const updateSessionUserRecipeValues = (options: Options) => { return (options.client ?? _heyApiClient).put({ url: '/sessions/{session_id}/user_recipe_values', diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index bf69f3e86789..0b8d9af476b6 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -341,6 +341,10 @@ export type ImageContent = { mimeType: string; }; +export type ImportSessionRequest = { + json: string; +}; + export type InspectJobResponse = { processStartTime?: string | null; runningDurationSeconds?: number | null; @@ -2177,6 +2181,37 @@ export type ListSessionsResponses = { export type ListSessionsResponse = ListSessionsResponses[keyof ListSessionsResponses]; +export type ImportSessionData = { + body: ImportSessionRequest; + path?: never; + query?: never; + url: '/sessions/import'; +}; + +export type ImportSessionErrors = { + /** + * Bad request - Invalid JSON + */ + 400: unknown; + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ImportSessionResponses = { + /** + * Session imported successfully + */ + 200: Session; +}; + +export type ImportSessionResponse = ImportSessionResponses[keyof ImportSessionResponses]; + export type GetSessionInsightsData = { body?: never; path?: never; @@ -2312,6 +2347,42 @@ export type UpdateSessionDescriptionResponses = { 200: unknown; }; +export type ExportSessionData = { + body?: never; + path: { + /** + * Unique identifier for the session + */ + session_id: string; + }; + query?: never; + url: '/sessions/{session_id}/export'; +}; + +export type ExportSessionErrors = { + /** + * Unauthorized - Invalid or missing API key + */ + 401: unknown; + /** + * Session not found + */ + 404: unknown; + /** + * Internal server error + */ + 500: unknown; +}; + +export type ExportSessionResponses = { + /** + * Session exported successfully + */ + 200: string; +}; + +export type ExportSessionResponse = ExportSessionResponses[keyof ExportSessionResponses]; + export type UpdateSessionUserRecipeValuesData = { body: UpdateSessionUserRecipeValuesRequest; path: { diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index cc21a1514e37..2f6d8a67eb91 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -7,6 +7,8 @@ import { Folder, Edit2, Trash2, + Download, + Upload, } from 'lucide-react'; import { Card } from '../ui/card'; import { Button } from '../ui/button'; @@ -19,7 +21,14 @@ 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'; +import { + deleteSession, + exportSession, + importSession, + listSessions, + Session, + updateSessionDescription, +} from '../../api'; interface EditSessionModalProps { session: Session | null; @@ -210,6 +219,8 @@ const SessionListView: React.FC = React.memo( } }; + const fileInputRef = useRef(null); + const visibleDateGroups = useMemo(() => { return dateGroups.slice(0, visibleGroupsCount); }, [dateGroups, visibleGroupsCount]); @@ -427,14 +438,66 @@ const SessionListView: React.FC = React.memo( setSessionToDelete(null); }, []); + const handleExportSession = useCallback(async (session: Session, e: React.MouseEvent) => { + e.stopPropagation(); + + const response = await exportSession({ + path: { session_id: session.id }, + throwOnError: true, + }); + + const json = response.data; + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${session.description || session.id}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success('Session exported successfully'); + }, []); + + const handleImportClick = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleImportSession = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const json = await file.text(); + await importSession({ + body: { json }, + throwOnError: true, + }); + + toast.success('Session imported successfully'); + await loadSessions(); + } catch (error) { + toast.error(`Failed to import session: ${error}`); + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }, + [loadSessions] + ); + const SessionItem = React.memo(function SessionItem({ session, onEditClick, onDeleteClick, + onExportClick, }: { session: Session; onEditClick: (session: Session) => void; onDeleteClick: (session: Session) => void; + onExportClick: (session: Session, e: React.MouseEvent) => void; }) { const handleEditClick = useCallback( (e: React.MouseEvent) => { @@ -456,6 +519,13 @@ const SessionListView: React.FC = React.memo( onSelectSession(session.id); }, [session.id]); + const handleExportClick = useCallback( + (e: React.MouseEvent) => { + onExportClick(session, e); + }, + [onExportClick, session] + ); + return ( = React.memo( > +
@@ -598,6 +675,7 @@ const SessionListView: React.FC = React.memo( session={session} onEditClick={handleEditSession} onDeleteClick={handleDeleteSession} + onExportClick={handleExportSession} /> ))}
@@ -624,6 +702,15 @@ const SessionListView: React.FC = React.memo(

Chat history

+

View and search your past conversations with Goose. @@ -701,6 +788,14 @@ const SessionListView: React.FC = React.memo(

+ +