feat: Add session description editing functionality (#3819)
Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
committed by
GitHub
parent
7e40b930da
commit
a5371af4c1
@@ -7,14 +7,14 @@ use crate::state::AppState;
|
|||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, State},
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
routing::get,
|
routing::{get, put},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use goose::message::Message;
|
use goose::message::Message;
|
||||||
use goose::session;
|
use goose::session;
|
||||||
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
|
||||||
use goose::session::SessionMetadata;
|
use goose::session::SessionMetadata;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@ pub struct SessionHistoryResponse {
|
|||||||
messages: Vec<Message>,
|
messages: Vec<Message>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize, ToSchema)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateSessionMetadataRequest {
|
||||||
|
/// Updated description (name) for the session (max 200 characters)
|
||||||
|
description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DESCRIPTION_LENGTH: usize = 200;
|
||||||
|
|
||||||
#[derive(Serialize, ToSchema, Debug)]
|
#[derive(Serialize, ToSchema, Debug)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct SessionInsights {
|
pub struct SessionInsights {
|
||||||
@@ -300,6 +309,56 @@ async fn get_activity_heatmap(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[utoipa::path(
|
||||||
|
put,
|
||||||
|
path = "/sessions/{session_id}/metadata",
|
||||||
|
request_body = UpdateSessionMetadataRequest,
|
||||||
|
params(
|
||||||
|
("session_id" = String, Path, description = "Unique identifier for the session")
|
||||||
|
),
|
||||||
|
responses(
|
||||||
|
(status = 200, description = "Session metadata updated successfully"),
|
||||||
|
(status = 400, description = "Bad request - Description too long (max 200 characters)"),
|
||||||
|
(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"
|
||||||
|
)]
|
||||||
|
// Update session metadata
|
||||||
|
async fn update_session_metadata(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(session_id): Path<String>,
|
||||||
|
Json(request): Json<UpdateSessionMetadataRequest>,
|
||||||
|
) -> Result<StatusCode, StatusCode> {
|
||||||
|
verify_secret_key(&headers, &state)?;
|
||||||
|
|
||||||
|
// Validate description length
|
||||||
|
if request.description.len() > MAX_DESCRIPTION_LENGTH {
|
||||||
|
return Err(StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session_path = session::get_path(session::Identifier::Name(session_id.clone()))
|
||||||
|
.map_err(|_| StatusCode::BAD_REQUEST)?;
|
||||||
|
|
||||||
|
// Read current metadata
|
||||||
|
let mut metadata = session::read_metadata(&session_path).map_err(|_| StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
// Update description
|
||||||
|
metadata.description = request.description;
|
||||||
|
|
||||||
|
// Save updated metadata
|
||||||
|
session::update_metadata(&session_path, &metadata)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(StatusCode::OK)
|
||||||
|
}
|
||||||
|
|
||||||
// Configure routes for this module
|
// Configure routes for this module
|
||||||
pub fn routes(state: Arc<AppState>) -> Router {
|
pub fn routes(state: Arc<AppState>) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
@@ -307,5 +366,67 @@ pub fn routes(state: Arc<AppState>) -> Router {
|
|||||||
.route("/sessions/{session_id}", get(get_session_history))
|
.route("/sessions/{session_id}", get(get_session_history))
|
||||||
.route("/sessions/insights", get(get_session_insights))
|
.route("/sessions/insights", get(get_session_insights))
|
||||||
.route("/sessions/activity-heatmap", get(get_activity_heatmap))
|
.route("/sessions/activity-heatmap", get(get_activity_heatmap))
|
||||||
|
.route(
|
||||||
|
"/sessions/{session_id}/metadata",
|
||||||
|
put(update_session_metadata),
|
||||||
|
)
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_session_metadata_request_deserialization() {
|
||||||
|
// Test that our request struct can be deserialized properly
|
||||||
|
let json = r#"{"description": "test description"}"#;
|
||||||
|
let request: UpdateSessionMetadataRequest = serde_json::from_str(json).unwrap();
|
||||||
|
assert_eq!(request.description, "test description");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_update_session_metadata_request_validation() {
|
||||||
|
// Test empty description
|
||||||
|
let empty_request = UpdateSessionMetadataRequest {
|
||||||
|
description: "".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(empty_request.description, "");
|
||||||
|
|
||||||
|
// Test normal description
|
||||||
|
let normal_request = UpdateSessionMetadataRequest {
|
||||||
|
description: "My Session Name".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(normal_request.description, "My Session Name");
|
||||||
|
|
||||||
|
// Test description at max length (should be valid)
|
||||||
|
let max_length_description = "A".repeat(MAX_DESCRIPTION_LENGTH);
|
||||||
|
let max_request = UpdateSessionMetadataRequest {
|
||||||
|
description: max_length_description.clone(),
|
||||||
|
};
|
||||||
|
assert_eq!(max_request.description, max_length_description);
|
||||||
|
assert_eq!(max_request.description.len(), MAX_DESCRIPTION_LENGTH);
|
||||||
|
|
||||||
|
// Test description over max length
|
||||||
|
let over_max_description = "A".repeat(MAX_DESCRIPTION_LENGTH + 1);
|
||||||
|
let over_max_request = UpdateSessionMetadataRequest {
|
||||||
|
description: over_max_description.clone(),
|
||||||
|
};
|
||||||
|
assert_eq!(over_max_request.description, over_max_description);
|
||||||
|
assert!(over_max_request.description.len() > MAX_DESCRIPTION_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_description_length_validation() {
|
||||||
|
// Test the validation logic used in the endpoint
|
||||||
|
let valid_description = "A".repeat(MAX_DESCRIPTION_LENGTH);
|
||||||
|
assert!(valid_description.len() <= MAX_DESCRIPTION_LENGTH);
|
||||||
|
|
||||||
|
let invalid_description = "A".repeat(MAX_DESCRIPTION_LENGTH + 1);
|
||||||
|
assert!(invalid_description.len() > MAX_DESCRIPTION_LENGTH);
|
||||||
|
|
||||||
|
// Test edge cases
|
||||||
|
assert!(String::new().len() <= MAX_DESCRIPTION_LENGTH); // Empty string
|
||||||
|
assert!("Short".len() <= MAX_DESCRIPTION_LENGTH); // Short string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback, useMemo, startTransition } from 'react';
|
import React, { useEffect, useState, useRef, useCallback, useMemo, startTransition } from 'react';
|
||||||
import { MessageSquareText, Target, AlertCircle, Calendar, Folder } from 'lucide-react';
|
import { MessageSquareText, Target, AlertCircle, Calendar, Folder, Edit2 } from 'lucide-react';
|
||||||
import { fetchSessions, type Session } from '../../sessions';
|
import { fetchSessions, updateSessionMetadata, type Session } from '../../sessions';
|
||||||
import { Card } from '../ui/card';
|
import { Card } from '../ui/card';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { ScrollArea } from '../ui/scroll-area';
|
import { ScrollArea } from '../ui/scroll-area';
|
||||||
@@ -11,6 +11,125 @@ import { SearchHighlighter } from '../../utils/searchHighlighter';
|
|||||||
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
import { MainPanelLayout } from '../Layout/MainPanelLayout';
|
||||||
import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils';
|
import { groupSessionsByDate, type DateGroup } from '../../utils/dateUtils';
|
||||||
import { Skeleton } from '../ui/skeleton';
|
import { Skeleton } from '../ui/skeleton';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
|
||||||
|
interface EditSessionModalProps {
|
||||||
|
session: Session | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (sessionId: string, newDescription: string) => Promise<void>;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EditSessionModal = React.memo<EditSessionModalProps>(
|
||||||
|
({ session, isOpen, onClose, onSave, disabled = false }) => {
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session && isOpen) {
|
||||||
|
setDescription(session.metadata.description || session.id);
|
||||||
|
} else if (!isOpen) {
|
||||||
|
// Reset state when modal closes
|
||||||
|
setDescription('');
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
}, [session, isOpen]);
|
||||||
|
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
if (!session || disabled) return;
|
||||||
|
|
||||||
|
const trimmedDescription = description.trim();
|
||||||
|
if (trimmedDescription === session.metadata.description) {
|
||||||
|
onClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUpdating(true);
|
||||||
|
try {
|
||||||
|
await updateSessionMetadata(session.id, trimmedDescription);
|
||||||
|
await onSave(session.id, trimmedDescription);
|
||||||
|
|
||||||
|
// Close modal, then show success toast on a timeout to let the UI update complete.
|
||||||
|
onClose();
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.success('Session description updated successfully');
|
||||||
|
}, 300);
|
||||||
|
} catch (error) {
|
||||||
|
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);
|
||||||
|
} finally {
|
||||||
|
setIsUpdating(false);
|
||||||
|
}
|
||||||
|
}, [session, description, onSave, onClose, disabled]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
if (!isUpdating) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, [onClose, isUpdating]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && !isUpdating) {
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape' && !isUpdating) {
|
||||||
|
handleCancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSave, handleCancel, isUpdating]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setDescription(e.target.value);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isOpen || !session) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50">
|
||||||
|
<div className="bg-background-default border border-border-subtle rounded-lg p-6 w-[500px] max-w-[90vw]">
|
||||||
|
<h3 className="text-lg font-medium text-text-standard mb-4">Edit Session Description</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
id="session-description"
|
||||||
|
type="text"
|
||||||
|
value={description}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full p-3 border border-border-subtle rounded-lg bg-background-default text-text-standard focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="Enter session description"
|
||||||
|
autoFocus
|
||||||
|
maxLength={200}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
disabled={isUpdating || disabled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 mt-6">
|
||||||
|
<Button onClick={handleCancel} variant="ghost" disabled={isUpdating || disabled}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!description.trim() || isUpdating || disabled}
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
{isUpdating ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
EditSessionModal.displayName = 'EditSessionModal';
|
||||||
|
|
||||||
// Debounce hook for search
|
// Debounce hook for search
|
||||||
function useDebounce<T>(value: T, delay: number): T {
|
function useDebounce<T>(value: T, delay: number): T {
|
||||||
@@ -52,6 +171,10 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(({ onSelectSe
|
|||||||
currentIndex: number;
|
currentIndex: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Edit modal state
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [editingSession, setEditingSession] = useState<Session | null>(null);
|
||||||
|
|
||||||
// Search state for debouncing
|
// Search state for debouncing
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||||
@@ -184,15 +307,63 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(({ onSelectSe
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render a session item
|
// Handle modal close
|
||||||
const SessionItem = React.memo(function SessionItem({ session }: { session: Session }) {
|
const handleModalClose = useCallback(() => {
|
||||||
|
setShowEditModal(false);
|
||||||
|
setEditingSession(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => {
|
||||||
|
// Update state immediately for optimistic UI
|
||||||
|
setSessions((prevSessions) =>
|
||||||
|
prevSessions.map((s) =>
|
||||||
|
s.id === sessionId ? { ...s, metadata: { ...s.metadata, description: newDescription } } : s
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEditSession = useCallback((session: Session) => {
|
||||||
|
setEditingSession(session);
|
||||||
|
setShowEditModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const SessionItem = React.memo(function SessionItem({
|
||||||
|
session,
|
||||||
|
onEditClick,
|
||||||
|
}: {
|
||||||
|
session: Session;
|
||||||
|
onEditClick: (session: Session) => void;
|
||||||
|
}) {
|
||||||
|
const handleEditClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation(); // Prevent card click
|
||||||
|
onEditClick(session);
|
||||||
|
},
|
||||||
|
[onEditClick, session]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCardClick = useCallback(() => {
|
||||||
|
onSelectSession(session.id);
|
||||||
|
}, [session.id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
onClick={() => onSelectSession(session.id)}
|
onClick={handleCardClick}
|
||||||
className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between"
|
className="session-item h-full py-3 px-4 hover:shadow-default cursor-pointer transition-all duration-150 flex flex-col justify-between relative group"
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
onClick={handleEditClick}
|
||||||
|
className="absolute top-3 right-4 p-2 rounded opacity-0 group-hover:opacity-100 transition-opacity hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
|
||||||
|
title="Edit session name"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" />
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-base truncate mb-1">{session.metadata.description || session.id}</h3>
|
<h3 className="text-base mb-1 pr-6 break-words">
|
||||||
|
{session.metadata.description || session.id}
|
||||||
|
</h3>
|
||||||
|
|
||||||
<div className="flex items-center text-text-muted text-xs mb-1">
|
<div className="flex items-center text-text-muted text-xs mb-1">
|
||||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||||
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
|
<span>{formatMessageTimestamp(Date.parse(session.modified) / 1000)}</span>
|
||||||
@@ -303,7 +474,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(({ onSelectSe
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
{group.sessions.map((session) => (
|
{group.sessions.map((session) => (
|
||||||
<SessionItem key={session.id} session={session} />
|
<SessionItem key={session.id} session={session} onEditClick={handleEditSession} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -313,88 +484,97 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(({ onSelectSe
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MainPanelLayout>
|
<>
|
||||||
<div className="flex-1 flex flex-col min-h-0">
|
<MainPanelLayout>
|
||||||
<div className="bg-background-default px-8 pb-8 pt-16">
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex flex-col page-transition">
|
<div className="bg-background-default px-8 pb-8 pt-16">
|
||||||
<div className="flex justify-between items-center mb-1">
|
<div className="flex flex-col page-transition">
|
||||||
<h1 className="text-4xl font-light">Chat history</h1>
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<h1 className="text-4xl font-light">Chat history</h1>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-muted mb-4">
|
||||||
|
View and search your past conversations with Goose.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-muted mb-4">
|
|
||||||
View and search your past conversations with Goose.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 relative px-8">
|
<div className="flex-1 min-h-0 relative px-8">
|
||||||
<ScrollArea className="h-full" data-search-scroll-area>
|
<ScrollArea className="h-full" data-search-scroll-area>
|
||||||
<div ref={containerRef} className="h-full relative">
|
<div ref={containerRef} className="h-full relative">
|
||||||
<SearchView
|
<SearchView
|
||||||
onSearch={handleSearch}
|
onSearch={handleSearch}
|
||||||
onNavigate={handleSearchNavigation}
|
onNavigate={handleSearchNavigation}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
className="relative"
|
className="relative"
|
||||||
>
|
|
||||||
{/* Skeleton layer - always rendered but conditionally visible */}
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 transition-opacity duration-300 ${
|
|
||||||
isLoading || showSkeleton
|
|
||||||
? 'opacity-100 z-10'
|
|
||||||
: 'opacity-0 z-0 pointer-events-none'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="space-y-8">
|
{/* Skeleton layer - always rendered but conditionally visible */}
|
||||||
{/* Today section */}
|
<div
|
||||||
<div className="space-y-4">
|
className={`absolute inset-0 transition-opacity duration-300 ${
|
||||||
<Skeleton className="h-6 w-16" />
|
isLoading || showSkeleton
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
? 'opacity-100 z-10'
|
||||||
<SessionSkeleton variant={0} />
|
: 'opacity-0 z-0 pointer-events-none'
|
||||||
<SessionSkeleton variant={1} />
|
}`}
|
||||||
<SessionSkeleton variant={2} />
|
>
|
||||||
<SessionSkeleton variant={3} />
|
<div className="space-y-8">
|
||||||
<SessionSkeleton variant={0} />
|
{/* Today section */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-6 w-16" />
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
|
<SessionSkeleton variant={0} />
|
||||||
|
<SessionSkeleton variant={1} />
|
||||||
|
<SessionSkeleton variant={2} />
|
||||||
|
<SessionSkeleton variant={3} />
|
||||||
|
<SessionSkeleton variant={0} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Yesterday section */}
|
{/* Yesterday section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-6 w-20" />
|
<Skeleton className="h-6 w-20" />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
<SessionSkeleton variant={1} />
|
<SessionSkeleton variant={1} />
|
||||||
<SessionSkeleton variant={2} />
|
<SessionSkeleton variant={2} />
|
||||||
<SessionSkeleton variant={3} />
|
<SessionSkeleton variant={3} />
|
||||||
<SessionSkeleton variant={0} />
|
<SessionSkeleton variant={0} />
|
||||||
<SessionSkeleton variant={1} />
|
<SessionSkeleton variant={1} />
|
||||||
<SessionSkeleton variant={2} />
|
<SessionSkeleton variant={2} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Additional section */}
|
{/* Additional section */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Skeleton className="h-6 w-24" />
|
<Skeleton className="h-6 w-24" />
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||||
<SessionSkeleton variant={3} />
|
<SessionSkeleton variant={3} />
|
||||||
<SessionSkeleton variant={0} />
|
<SessionSkeleton variant={0} />
|
||||||
<SessionSkeleton variant={1} />
|
<SessionSkeleton variant={1} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content layer - always rendered but conditionally visible */}
|
{/* Content layer - always rendered but conditionally visible */}
|
||||||
<div
|
<div
|
||||||
className={`relative transition-opacity duration-300 ${
|
className={`relative transition-opacity duration-300 ${
|
||||||
showContent ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
showContent ? 'opacity-100 z-10' : 'opacity-0 z-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{renderActualContent()}
|
{renderActualContent()}
|
||||||
</div>
|
</div>
|
||||||
</SearchView>
|
</SearchView>
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</MainPanelLayout>
|
||||||
</MainPanelLayout>
|
|
||||||
|
<EditSessionModal
|
||||||
|
session={editingSession}
|
||||||
|
isOpen={showEditModal}
|
||||||
|
onClose={handleModalClose}
|
||||||
|
onSave={handleModalSave}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Message } from './types/message';
|
import { Message } from './types/message';
|
||||||
import { getSessionHistory, listSessions, SessionInfo } from './api';
|
import { getSessionHistory, listSessions, SessionInfo } from './api';
|
||||||
import { convertApiMessageToFrontendMessage } from './components/context_management';
|
import { convertApiMessageToFrontendMessage } from './components/context_management';
|
||||||
|
import { getApiUrl } from './config';
|
||||||
|
|
||||||
export interface SessionMetadata {
|
export interface SessionMetadata {
|
||||||
description: string;
|
description: string;
|
||||||
@@ -126,3 +127,33 @@ export async function fetchSessionDetails(sessionId: string): Promise<SessionDet
|
|||||||
throw 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> {
|
||||||
|
try {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error updating session metadata for ${sessionId}:`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user