Art vandelay: Import & Export (#5053)

Co-authored-by: Douwe Osinga <douwe@squareup.com>
This commit is contained in:
Douwe Osinga
2025-10-07 21:07:40 -04:00
committed by GitHub
parent 00302f5c89
commit b179be4cff
7 changed files with 467 additions and 2 deletions
+3
View File
@@ -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,
+59
View File
@@ -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<String, String>,
}
#[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<String>) -> Result<StatusCode, St
Ok(StatusCode::OK)
}
#[utoipa::path(
get,
path = "/sessions/{session_id}/export",
params(
("session_id" = String, Path, description = "Unique identifier for the session")
),
responses(
(status = 200, description = "Session exported successfully", body = String),
(status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session not found"),
(status = 500, description = "Internal server error")
),
security(
("api_key" = [])
),
tag = "Session Management"
)]
async fn export_session(Path(session_id): Path<String>) -> Result<Json<String>, 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<ImportSessionRequest>,
) -> Result<Json<Session>, StatusCode> {
let session = SessionManager::import_session(&request.json)
.await
.map_err(|_| StatusCode::BAD_REQUEST)?;
Ok(Json(session))
}
pub fn routes(state: Arc<AppState>) -> 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",
+119
View File
@@ -211,6 +211,14 @@ impl SessionManager {
Self::instance().await?.get_insights().await
}
pub async fn export_session(id: &str) -> Result<String> {
Self::instance().await?.export_session(id).await
}
pub async fn import_session(json: &str) -> Result<Session> {
Self::instance().await?.import_session(json).await
}
pub async fn maybe_update_description(id: &str, provider: Arc<dyn Provider>) -> 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<String> {
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<Session> {
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);
}
}
+100
View File
@@ -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": {
+19 -1
View File
@@ -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<TData extends TDataShape = TDataShape, ThrowOnError extends boolean = boolean> = ClientOptions<TData, ThrowOnError> & {
@@ -457,6 +457,17 @@ export const listSessions = <ThrowOnError extends boolean = false>(options?: Opt
});
};
export const importSession = <ThrowOnError extends boolean = false>(options: Options<ImportSessionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<ImportSessionResponses, ImportSessionErrors, ThrowOnError>({
url: '/sessions/import',
...options,
headers: {
'Content-Type': 'application/json',
...options.headers
}
});
};
export const getSessionInsights = <ThrowOnError extends boolean = false>(options?: Options<GetSessionInsightsData, ThrowOnError>) => {
return (options?.client ?? _heyApiClient).get<GetSessionInsightsResponses, GetSessionInsightsErrors, ThrowOnError>({
url: '/sessions/insights',
@@ -489,6 +500,13 @@ export const updateSessionDescription = <ThrowOnError extends boolean = false>(o
});
};
export const exportSession = <ThrowOnError extends boolean = false>(options: Options<ExportSessionData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).get<ExportSessionResponses, ExportSessionErrors, ThrowOnError>({
url: '/sessions/{session_id}/export',
...options
});
};
export const updateSessionUserRecipeValues = <ThrowOnError extends boolean = false>(options: Options<UpdateSessionUserRecipeValuesData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).put<UpdateSessionUserRecipeValuesResponses, UpdateSessionUserRecipeValuesErrors, ThrowOnError>({
url: '/sessions/{session_id}/user_recipe_values',
+71
View File
@@ -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: {
@@ -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<SessionListViewProps> = React.memo(
}
};
const fileInputRef = useRef<HTMLInputElement>(null);
const visibleDateGroups = useMemo(() => {
return dateGroups.slice(0, visibleGroupsCount);
}, [dateGroups, visibleGroupsCount]);
@@ -427,14 +438,66 @@ const SessionListView: React.FC<SessionListViewProps> = 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<HTMLInputElement>) => {
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<SessionListViewProps> = React.memo(
onSelectSession(session.id);
}, [session.id]);
const handleExportClick = useCallback(
(e: React.MouseEvent) => {
onExportClick(session, e);
},
[onExportClick, session]
);
return (
<Card
onClick={handleCardClick}
@@ -477,6 +547,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
>
<Trash2 className="w-3 h-3 text-red-500 hover:text-red-600" />
</button>
<button
onClick={handleExportClick}
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
title="Export session"
>
<Download className="w-3 h-3 text-textSubtle hover:text-textStandard" />
</button>
</div>
<div className="flex-1">
@@ -598,6 +675,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
session={session}
onEditClick={handleEditSession}
onDeleteClick={handleDeleteSession}
onExportClick={handleExportSession}
/>
))}
</div>
@@ -624,6 +702,15 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
<div className="flex flex-col page-transition">
<div className="flex justify-between items-center mb-1">
<h1 className="text-4xl font-light">Chat history</h1>
<Button
onClick={handleImportClick}
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<Upload className="w-4 h-4" />
Import Session
</Button>
</div>
<p className="text-sm text-text-muted mb-4">
View and search your past conversations with Goose.
@@ -701,6 +788,14 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
</div>
</MainPanelLayout>
<input
ref={fileInputRef}
type="file"
accept=".json"
onChange={handleImportSession}
className="hidden"
/>
<EditSessionModal
session={editingSession}
isOpen={showEditModal}