Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 120 additions & 2 deletions crates/goose-server/src/routes/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ use crate::state::AppState;
use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
routing::get,
routing::{get, put},
Json, Router,
};
use goose::message::Message;
use goose::session;
use goose::session::info::{get_valid_sorted_sessions, SessionInfo, SortOrder};
use goose::session::SessionMetadata;
use serde::Serialize;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;

#[derive(Serialize, ToSchema)]
Expand All @@ -33,6 +33,15 @@ pub struct SessionHistoryResponse {
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;

#[utoipa::path(
get,
path = "/sessions",
Expand Down Expand Up @@ -106,10 +115,119 @@ async fn get_session_history(
}))
}

#[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
pub fn routes(state: Arc<AppState>) -> Router {
Router::new()
.route("/sessions", get(list_sessions))
.route("/sessions/{session_id}", get(get_session_history))
.route("/sessions/{session_id}/metadata", put(update_session_metadata))
.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
}
}
26 changes: 26 additions & 0 deletions ui/desktop/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import LayingEggLoader from './LayingEggLoader';
import { fetchSessionDetails, generateSessionId } from '../sessions';
import 'react-toastify/dist/ReactToastify.css';
import { useMessageStream } from '../hooks/useMessageStream';
import { useSessionMetadata } from '../hooks/useSessionMetadata';
import { SessionSummaryModal } from './context_management/SessionSummaryModal';
import ParameterInputModal from './ParameterInputModal';
import { Recipe } from '../recipe';
Expand Down Expand Up @@ -154,6 +155,10 @@ function ChatContent({
getContextHandlerType,
} = useChatContextManager();

// Use the session metadata hook
const { sessionName, isSessionNameSet, refreshSessionName, updateSessionName } =
useSessionMetadata(chat.id);

useEffect(() => {
// Log all messages when the component first mounts
window.electron.logInfo(
Expand Down Expand Up @@ -231,6 +236,18 @@ function ChatContent({
},
});

// Refresh session name after messages are added (for auto-generated names)
useEffect(() => {
if (messages.length > 0 && !isSessionNameSet) {
// Delay to allow server to process and generate name
const timer = setTimeout(() => {
refreshSessionName();
}, 2000);
return () => window.clearTimeout(timer);
}
return undefined;
}, [messages.length, isSessionNameSet, refreshSessionName]);

// Wrap append to store messages in global history
const append = useCallback(
(messageOrString: Message | string) => {
Expand Down Expand Up @@ -754,6 +771,15 @@ function ChatContent({
hasMessages={hasMessages}
setView={setView}
setIsGoosehintsModalOpen={setIsGoosehintsModalOpen}
sessionId={chat.id}
sessionName={sessionName}
onSessionNameUpdated={(newName) => {
updateSessionName(newName);
setChat({
...chat,
title: newName,
});
}}
/>

<Card
Expand Down
81 changes: 49 additions & 32 deletions ui/desktop/src/components/more_menu/MoreMenuLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,24 @@ import MoreMenu from './MoreMenu';
import type { View, ViewOptions } from '../../App';
import { Document } from '../icons';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/Tooltip';
import { SessionHeader } from '../session/SessionHeader';

export default function MoreMenuLayout({
hasMessages,
showMenu = true,
setView,
setIsGoosehintsModalOpen,
sessionId,
sessionName,
onSessionNameUpdated,
}: {
hasMessages?: boolean;
showMenu?: boolean;
setView?: (view: View, viewOptions?: ViewOptions) => void;
setIsGoosehintsModalOpen?: (isOpen: boolean) => void;
sessionId?: string;
sessionName?: string | null;
onSessionNameUpdated?: (newName: string) => void;
}) {
const [isTooltipOpen, setIsTooltipOpen] = useState(false);

Expand All @@ -29,40 +36,50 @@ export default function MoreMenuLayout({
<div
className={`flex items-center justify-between w-full h-full ${safeIsMacOS ? 'pl-[86px]' : 'pl-[8px]'} pr-4`}
>
<TooltipProvider>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<button
className="z-[100] no-drag hover:cursor-pointer border border-borderSubtle hover:border-borderStandard rounded-lg p-2 pr-3 text-textSubtle hover:text-textStandard text-sm flex items-center transition-colors [&>svg]:size-4 "
onClick={async () => {
if (hasMessages) {
window.electron.directoryChooser();
} else {
window.electron.directoryChooser(true);
}
}}
style={{ minWidth: 0 }}
>
<Document className="mr-1" />
<span
className="flex-grow block text-ellipsis overflow-hidden"
style={{
direction: 'rtl',
textAlign: 'left',
unicodeBidi: 'plaintext',
minWidth: 0,
maxWidth: '100%',
<div className="flex items-center space-x-2">
<TooltipProvider>
<Tooltip open={isTooltipOpen} onOpenChange={setIsTooltipOpen}>
<TooltipTrigger asChild>
<button
className="z-[100] no-drag hover:cursor-pointer border border-borderSubtle hover:border-borderStandard rounded-lg p-2 pr-3 text-textSubtle hover:text-textStandard text-sm flex items-center transition-colors [&>svg]:size-4 "
onClick={async () => {
if (hasMessages) {
window.electron.directoryChooser();
} else {
window.electron.directoryChooser(true);
}
}}
style={{ minWidth: 0 }}
>
{String(window.appConfig.get('GOOSE_WORKING_DIR'))}
</span>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
{window.appConfig.get('GOOSE_WORKING_DIR') as string}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Document className="mr-1" />
<span
className="flex-grow block text-ellipsis overflow-hidden"
style={{
direction: 'rtl',
textAlign: 'left',
unicodeBidi: 'plaintext',
minWidth: 0,
maxWidth: '100%',
}}
>
{String(window.appConfig.get('GOOSE_WORKING_DIR'))}
</span>
</button>
</TooltipTrigger>
<TooltipContent className="max-w-96 overflow-auto scrollbar-thin" side="top">
{window.appConfig.get('GOOSE_WORKING_DIR') as string}
</TooltipContent>
</Tooltip>
</TooltipProvider>

{sessionId && sessionName && (
<SessionHeader
sessionId={sessionId}
sessionName={sessionName}
onNameUpdated={onSessionNameUpdated}
/>
)}
</div>

<MoreMenu
setView={setView || (() => {})}
Expand Down
Loading
Loading