Add session forking capability (#5882)

Co-authored-by: Zane Staggs <zane@squareup.com>
This commit is contained in:
Will Pfleger
2026-01-22 12:49:04 -05:00
committed by GitHub
parent f1a36b2bc3
commit 13bdff4bb5
13 changed files with 378 additions and 275 deletions
+84 -45
View File
@@ -322,15 +322,37 @@ async fn get_or_create_session_id(
let session_manager = SessionManager::instance(); let session_manager = SessionManager::instance();
let Some(id) = identifier else { let resolved_id = if resume {
return if resume { let Some(id) = identifier else {
let sessions = session_manager.list_sessions().await?; let sessions = session_manager.list_sessions().await?;
let session_id = sessions let session_id = sessions
.first() .first()
.map(|s| s.id.clone()) .map(|s| s.id.clone())
.ok_or_else(|| anyhow::anyhow!("No session found to resume"))?; .ok_or_else(|| anyhow::anyhow!("No session found to resume"))?;
Ok(Some(session_id)) return Ok(Some(session_id));
};
if let Some(session_id) = id.session_id {
session_id
} else if let Some(name) = id.name {
let sessions = session_manager.list_sessions().await?;
sessions
.into_iter()
.find(|s| s.name == name || s.id == name)
.map(|s| s.id)
.ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?
} else if let Some(path) = id.path {
path.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.ok_or_else(|| {
anyhow::anyhow!("Could not extract session ID from path: {:?}", path)
})?
} else { } else {
return Err(anyhow::anyhow!("Invalid identifier"));
}
} else {
let Some(id) = identifier else {
let session = session_manager let session = session_manager
.create_session( .create_session(
std::env::current_dir()?, std::env::current_dir()?,
@@ -338,58 +360,39 @@ async fn get_or_create_session_id(
SessionType::User, SessionType::User,
) )
.await?; .await?;
Ok(Some(session.id)) return Ok(Some(session.id));
}; };
};
if let Some(session_id) = id.session_id { if id.session_id.is_some() {
Ok(Some(session_id)) return Err(anyhow::anyhow!("Cannot use --session-id without --resume"));
} else if let Some(name) = id.name { }
if resume {
let sessions = session_manager.list_sessions().await?;
let session_id = sessions
.into_iter()
.find(|s| s.name == name || s.id == name)
.map(|s| s.id)
.ok_or_else(|| anyhow::anyhow!("No session found with name '{}'", name))?;
Ok(Some(session_id))
} else {
let session = session_manager
.create_session(std::env::current_dir()?, name.clone(), SessionType::User)
.await?;
let has_user_provided_name = id.name.is_some();
let name = id.name.unwrap_or_else(|| "CLI Session".to_string());
let session = session_manager
.create_session(std::env::current_dir()?, name.clone(), SessionType::User)
.await?;
if has_user_provided_name {
session_manager session_manager
.update(&session.id) .update(&session.id)
.user_provided_name(name) .user_provided_name(name)
.apply() .apply()
.await?; .await?;
Ok(Some(session.id))
} }
} else if let Some(path) = id.path {
let session_id = path return Ok(Some(session.id));
.file_stem() };
.and_then(|s| s.to_str())
.map(|s| s.to_string()) Ok(Some(resolved_id))
.ok_or_else(|| anyhow::anyhow!("Could not extract session ID from path: {:?}", path))?;
Ok(Some(session_id))
} else {
let session = session_manager
.create_session(
std::env::current_dir()?,
"CLI Session".to_string(),
SessionType::User,
)
.await?;
Ok(Some(session.id))
}
} }
async fn lookup_session_id(identifier: Identifier) -> Result<String> { async fn lookup_session_id(identifier: Identifier) -> Result<String> {
let session_manager = SessionManager::instance();
if let Some(session_id) = identifier.session_id { if let Some(session_id) = identifier.session_id {
Ok(session_id) Ok(session_id)
} else if let Some(name) = identifier.name { } else if let Some(name) = identifier.name {
let session_manager = SessionManager::instance();
let sessions = session_manager.list_sessions().await?; let sessions = session_manager.list_sessions().await?;
sessions sessions
.into_iter() .into_iter()
@@ -722,6 +725,15 @@ enum Command {
)] )]
resume: bool, resume: bool,
/// Fork a previous session (creates new session with copied history)
#[arg(
long,
requires = "resume",
help = "Fork a previous session (creates new session with copied history)",
long_help = "Create a new session by copying all messages from a previous session. Must be used with --resume. If --name or --session-id is provided, forks that specific session. Otherwise forks the most recently used session."
)]
fork: bool,
/// Show message history when resuming /// Show message history when resuming
#[arg( #[arg(
long, long,
@@ -1047,6 +1059,7 @@ async fn handle_session_subcommand(command: SessionCommand) -> Result<()> {
async fn handle_interactive_session( async fn handle_interactive_session(
identifier: Option<Identifier>, identifier: Option<Identifier>,
resume: bool, resume: bool,
fork: bool,
history: bool, history: bool,
session_opts: SessionOptions, session_opts: SessionOptions,
extension_opts: ExtensionOptions, extension_opts: ExtensionOptions,
@@ -1056,7 +1069,13 @@ async fn handle_interactive_session(
} }
let session_start = std::time::Instant::now(); let session_start = std::time::Instant::now();
let session_type = if resume { "resumed" } else { "new" }; let session_type = if fork {
"forked"
} else if resume {
"resumed"
} else {
"new"
};
tracing::info!( tracing::info!(
counter.goose.session_starts = 1, counter.goose.session_starts = 1,
@@ -1076,11 +1095,21 @@ async fn handle_interactive_session(
} }
} }
let session_id = get_or_create_session_id(identifier, resume, false).await?; let mut session_id = get_or_create_session_id(identifier, resume, false).await?;
if fork {
if let Some(id) = session_id {
let session_manager = SessionManager::instance();
let original = session_manager.get_session(&id, false).await?;
let copied = session_manager.copy_session(&id, original.name).await?;
session_id = Some(copied.id);
}
}
let mut session: crate::CliSession = build_session(SessionBuilderConfig { let mut session: crate::CliSession = build_session(SessionBuilderConfig {
session_id, session_id,
resume, resume,
fork,
no_session: false, no_session: false,
extensions: extension_opts.extensions, extensions: extension_opts.extensions,
streamable_http_extensions: extension_opts.streamable_http_extensions, streamable_http_extensions: extension_opts.streamable_http_extensions,
@@ -1099,7 +1128,7 @@ async fn handle_interactive_session(
}) })
.await; .await;
if resume && history { if (resume || fork) && history {
session.render_message_history(); session.render_message_history();
} }
@@ -1283,6 +1312,7 @@ async fn handle_run_command(
let mut session = build_session(SessionBuilderConfig { let mut session = build_session(SessionBuilderConfig {
session_id, session_id,
resume: run_behavior.resume, resume: run_behavior.resume,
fork: false,
no_session: run_behavior.no_session, no_session: run_behavior.no_session,
extensions: extension_opts.extensions, extensions: extension_opts.extensions,
streamable_http_extensions: extension_opts.streamable_http_extensions, streamable_http_extensions: extension_opts.streamable_http_extensions,
@@ -1407,6 +1437,7 @@ async fn handle_default_session() -> Result<()> {
let mut session = build_session(SessionBuilderConfig { let mut session = build_session(SessionBuilderConfig {
session_id, session_id,
resume: false, resume: false,
fork: false,
no_session: false, no_session: false,
extensions: Vec::new(), extensions: Vec::new(),
streamable_http_extensions: Vec::new(), streamable_http_extensions: Vec::new(),
@@ -1458,12 +1489,20 @@ pub async fn cli() -> anyhow::Result<()> {
command: None, command: None,
identifier, identifier,
resume, resume,
fork,
history, history,
session_opts, session_opts,
extension_opts, extension_opts,
}) => { }) => {
handle_interactive_session(identifier, resume, history, session_opts, extension_opts) handle_interactive_session(
.await identifier,
resume,
fork,
history,
session_opts,
extension_opts,
)
.await
} }
Some(Command::Project {}) => { Some(Command::Project {}) => {
handle_project_default()?; handle_project_default()?;
+1
View File
@@ -37,6 +37,7 @@ pub async fn agent_generator(
let base_session = build_session(SessionBuilderConfig { let base_session = build_session(SessionBuilderConfig {
session_id: Some(session_id), session_id: Some(session_id),
resume: false, resume: false,
fork: false,
no_session: false, no_session: false,
extensions: requirements.external, extensions: requirements.external,
streamable_http_extensions: requirements.streamable_http, streamable_http_extensions: requirements.streamable_http,
+5
View File
@@ -82,6 +82,8 @@ pub struct SessionBuilderConfig {
pub session_id: Option<String>, pub session_id: Option<String>,
/// Whether to resume an existing session /// Whether to resume an existing session
pub resume: bool, pub resume: bool,
/// Whether to fork an existing session (creates a copy of the original/existing session then resumes the copy)
pub fork: bool,
/// Whether to run without a session file /// Whether to run without a session file
pub no_session: bool, pub no_session: bool,
/// List of stdio extension commands to add /// List of stdio extension commands to add
@@ -121,6 +123,7 @@ impl Default for SessionBuilderConfig {
SessionBuilderConfig { SessionBuilderConfig {
session_id: None, session_id: None,
resume: false, resume: false,
fork: false,
no_session: false, no_session: false,
extensions: Vec::new(), extensions: Vec::new(),
streamable_http_extensions: Vec::new(), streamable_http_extensions: Vec::new(),
@@ -659,6 +662,7 @@ mod tests {
let config = SessionBuilderConfig { let config = SessionBuilderConfig {
session_id: None, session_id: None,
resume: false, resume: false,
fork: false,
no_session: false, no_session: false,
extensions: vec!["echo test".to_string()], extensions: vec!["echo test".to_string()],
streamable_http_extensions: vec!["http://localhost:8080/mcp".to_string()], streamable_http_extensions: vec!["http://localhost:8080/mcp".to_string()],
@@ -705,6 +709,7 @@ mod tests {
assert!(config.scheduled_job_id.is_none()); assert!(config.scheduled_job_id.is_none());
assert!(!config.interactive); assert!(!config.interactive);
assert!(!config.quiet); assert!(!config.quiet);
assert!(!config.fork);
} }
#[tokio::test] #[tokio::test]
+3 -4
View File
@@ -380,7 +380,7 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::export_session, super::routes::session::export_session,
super::routes::session::import_session, super::routes::session::import_session,
super::routes::session::update_session_user_recipe_values, super::routes::session::update_session_user_recipe_values,
super::routes::session::edit_message, super::routes::session::fork_session,
super::routes::session::get_session_extensions, super::routes::session::get_session_extensions,
super::routes::schedule::create_schedule, super::routes::schedule::create_schedule,
super::routes::schedule::list_schedules, super::routes::schedule::list_schedules,
@@ -442,9 +442,8 @@ derive_utoipa!(Icon as IconSchema);
super::routes::session::UpdateSessionNameRequest, super::routes::session::UpdateSessionNameRequest,
super::routes::session::UpdateSessionUserRecipeValuesRequest, super::routes::session::UpdateSessionUserRecipeValuesRequest,
super::routes::session::UpdateSessionUserRecipeValuesResponse, super::routes::session::UpdateSessionUserRecipeValuesResponse,
super::routes::session::EditType, super::routes::session::ForkRequest,
super::routes::session::EditMessageRequest, super::routes::session::ForkResponse,
super::routes::session::EditMessageResponse,
super::routes::session::SessionExtensionsResponse, super::routes::session::SessionExtensionsResponse,
Message, Message,
MessageContent, MessageContent,
+77 -64
View File
@@ -51,28 +51,17 @@ pub struct ImportSessionRequest {
json: String, json: String,
} }
#[derive(Debug, Deserialize, ToSchema)]
#[serde(rename_all = "lowercase")]
pub enum EditType {
Fork,
Edit,
}
#[derive(Deserialize, ToSchema)] #[derive(Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EditMessageRequest { pub struct ForkRequest {
timestamp: i64, timestamp: Option<i64>,
#[serde(default = "default_edit_type")] truncate: bool,
edit_type: EditType, copy: bool,
}
fn default_edit_type() -> EditType {
EditType::Fork
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct EditMessageResponse { pub struct ForkResponse {
session_id: String, session_id: String,
} }
@@ -369,16 +358,16 @@ async fn import_session(
#[utoipa::path( #[utoipa::path(
post, post,
path = "/sessions/{session_id}/edit_message", path = "/sessions/{session_id}/fork",
request_body = EditMessageRequest, request_body = ForkRequest,
params( params(
("session_id" = String, Path, description = "Unique identifier for the session") ("session_id" = String, Path, description = "Unique identifier for the session")
), ),
responses( responses(
(status = 200, description = "Session prepared for editing - frontend should submit the edited message", body = EditMessageResponse), (status = 200, description = "Session forked successfully", body = ForkResponse),
(status = 400, description = "Bad request - Invalid message timestamp"), (status = 400, description = "Bad request - truncate=true requires timestamp"),
(status = 401, description = "Unauthorized - Invalid or missing API key"), (status = 401, description = "Unauthorized - Invalid or missing API key"),
(status = 404, description = "Session or message not found"), (status = 404, description = "Session not found"),
(status = 500, description = "Internal server error") (status = 500, description = "Internal server error")
), ),
security( security(
@@ -386,51 +375,75 @@ async fn import_session(
), ),
tag = "Session Management" tag = "Session Management"
)] )]
async fn edit_message( async fn fork_session(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
Path(session_id): Path<String>, Path(session_id): Path<String>,
Json(request): Json<EditMessageRequest>, Json(request): Json<ForkRequest>,
) -> Result<Json<EditMessageResponse>, StatusCode> { ) -> Result<Json<ForkResponse>, ErrorResponse> {
let manager = state.session_manager(); if request.truncate && request.timestamp.is_none() {
match request.edit_type { return Err(ErrorResponse {
EditType::Fork => { message: "truncate=true requires a timestamp".to_string(),
let new_session = manager status: StatusCode::BAD_REQUEST,
.copy_session(&session_id, "(edited)".to_string()) });
.await
.map_err(|e| {
tracing::error!("Failed to copy session: {}", e);
goose::posthog::emit_error("session_copy_failed", &e.to_string());
StatusCode::INTERNAL_SERVER_ERROR
})?;
manager
.truncate_conversation(&new_session.id, request.timestamp)
.await
.map_err(|e| {
tracing::error!("Failed to truncate conversation: {}", e);
goose::posthog::emit_error("session_truncate_failed", &e.to_string());
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(EditMessageResponse {
session_id: new_session.id,
}))
}
EditType::Edit => {
manager
.truncate_conversation(&session_id, request.timestamp)
.await
.map_err(|e| {
tracing::error!("Failed to truncate conversation: {}", e);
goose::posthog::emit_error("session_truncate_failed", &e.to_string());
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(EditMessageResponse {
session_id: session_id.clone(),
}))
}
} }
let session_manager = state.session_manager();
let target_session_id = if request.copy {
let original = session_manager
.get_session(&session_id, false)
.await
.map_err(|e| {
tracing::error!("Failed to get session: {}", e);
goose::posthog::emit_error("session_get_failed", &e.to_string());
ErrorResponse {
message: if e.to_string().contains("not found") {
format!("Session {} not found", session_id)
} else {
format!("Failed to get session: {}", e)
},
status: if e.to_string().contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
},
}
})?;
let copied = session_manager
.copy_session(&session_id, original.name)
.await
.map_err(|e| {
tracing::error!("Failed to copy session: {}", e);
goose::posthog::emit_error("session_copy_failed", &e.to_string());
ErrorResponse {
message: format!("Failed to copy session: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
copied.id
} else {
session_id.clone()
};
if request.truncate {
session_manager
.truncate_conversation(&target_session_id, request.timestamp.unwrap_or(0))
.await
.map_err(|e| {
tracing::error!("Failed to truncate conversation: {}", e);
goose::posthog::emit_error("session_truncate_failed", &e.to_string());
ErrorResponse {
message: format!("Failed to truncate conversation: {}", e),
status: StatusCode::INTERNAL_SERVER_ERROR,
}
})?;
}
Ok(Json(ForkResponse {
session_id: target_session_id,
}))
} }
#[derive(Serialize, ToSchema)] #[derive(Serialize, ToSchema)]
@@ -487,7 +500,7 @@ pub fn routes(state: Arc<AppState>) -> Router {
"/sessions/{session_id}/user_recipe_values", "/sessions/{session_id}/user_recipe_values",
put(update_session_user_recipe_values), put(update_session_user_recipe_values),
) )
.route("/sessions/{session_id}/edit_message", post(edit_message)) .route("/sessions/{session_id}/fork", post(fork_session))
.route( .route(
"/sessions/{session_id}/extensions", "/sessions/{session_id}/extensions",
get(get_session_extensions), get(get_session_extensions),
+13 -4
View File
@@ -1285,14 +1285,23 @@ impl SessionStorage {
) )
.await?; .await?;
session_manager let mut builder = session_manager
.update(&new_session.id) .update(&new_session.id)
.extension_data(original_session.extension_data) .extension_data(original_session.extension_data)
.schedule_id(original_session.schedule_id) .schedule_id(original_session.schedule_id)
.recipe(original_session.recipe) .recipe(original_session.recipe)
.user_recipe_values(original_session.user_recipe_values) .user_recipe_values(original_session.user_recipe_values);
.apply()
.await?; // Preserve provider and model config from original session
if let Some(provider_name) = original_session.provider_name {
builder = builder.provider_name(provider_name);
}
if let Some(model_config) = original_session.model_config {
builder = builder.model_config(model_config);
}
builder.apply().await?;
if let Some(conversation) = original_session.conversation { if let Some(conversation) = original_session.conversation {
self.replace_conversation(&new_session.id, &conversation) self.replace_conversation(&new_session.id, &conversation)
+89 -91
View File
@@ -2472,64 +2472,6 @@
] ]
} }
}, },
"/sessions/{session_id}/edit_message": {
"post": {
"tags": [
"Session Management"
],
"operationId": "edit_message",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Unique identifier for the session",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditMessageRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session prepared for editing - frontend should submit the edited message",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/EditMessageResponse"
}
}
}
},
"400": {
"description": "Bad request - Invalid message timestamp"
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"404": {
"description": "Session or message not found"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/sessions/{session_id}/export": { "/sessions/{session_id}/export": {
"get": { "get": {
"tags": [ "tags": [
@@ -2620,6 +2562,64 @@
] ]
} }
}, },
"/sessions/{session_id}/fork": {
"post": {
"tags": [
"Session Management"
],
"operationId": "fork_session",
"parameters": [
{
"name": "session_id",
"in": "path",
"description": "Unique identifier for the session",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForkRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Session forked successfully",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ForkResponse"
}
}
}
},
"400": {
"description": "Bad request - truncate=true requires timestamp"
},
"401": {
"description": "Unauthorized - Invalid or missing API key"
},
"404": {
"description": "Session not found"
},
"500": {
"description": "Internal server error"
}
},
"security": [
{
"api_key": []
}
]
}
},
"/sessions/{session_id}/name": { "/sessions/{session_id}/name": {
"put": { "put": {
"tags": [ "tags": [
@@ -3453,39 +3453,6 @@
} }
} }
}, },
"EditMessageRequest": {
"type": "object",
"required": [
"timestamp"
],
"properties": {
"editType": {
"$ref": "#/components/schemas/EditType"
},
"timestamp": {
"type": "integer",
"format": "int64"
}
}
},
"EditMessageResponse": {
"type": "object",
"required": [
"sessionId"
],
"properties": {
"sessionId": {
"type": "string"
}
}
},
"EditType": {
"type": "string",
"enum": [
"fork",
"edit"
]
},
"EmbeddedResource": { "EmbeddedResource": {
"type": "object", "type": "object",
"required": [ "required": [
@@ -3961,6 +3928,37 @@
} }
} }
}, },
"ForkRequest": {
"type": "object",
"required": [
"truncate",
"copy"
],
"properties": {
"copy": {
"type": "boolean"
},
"timestamp": {
"type": "integer",
"format": "int64",
"nullable": true
},
"truncate": {
"type": "boolean"
}
}
},
"ForkResponse": {
"type": "object",
"required": [
"sessionId"
],
"properties": {
"sessionId": {
"type": "string"
}
}
},
"FrontendToolRequest": { "FrontendToolRequest": {
"type": "object", "type": "object",
"required": [ "required": [
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+50 -51
View File
@@ -189,17 +189,6 @@ export type DetectProviderResponse = {
provider_name: string; provider_name: string;
}; };
export type EditMessageRequest = {
editType?: EditType;
timestamp: number;
};
export type EditMessageResponse = {
sessionId: string;
};
export type EditType = 'fork' | 'edit';
export type EmbeddedResource = { export type EmbeddedResource = {
_meta?: { _meta?: {
[key: string]: unknown; [key: string]: unknown;
@@ -352,6 +341,16 @@ export type ExtensionResponse = {
warnings?: Array<string>; warnings?: Array<string>;
}; };
export type ForkRequest = {
copy: boolean;
timestamp?: number | null;
truncate: boolean;
};
export type ForkResponse = {
sessionId: string;
};
export type FrontendToolRequest = { export type FrontendToolRequest = {
id: string; id: string;
toolCall: { toolCall: {
@@ -3178,46 +3177,6 @@ export type GetSessionResponses = {
export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses]; export type GetSessionResponse = GetSessionResponses[keyof GetSessionResponses];
export type EditMessageData = {
body: EditMessageRequest;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}/edit_message';
};
export type EditMessageErrors = {
/**
* Bad request - Invalid message timestamp
*/
400: unknown;
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session or message not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type EditMessageResponses = {
/**
* Session prepared for editing - frontend should submit the edited message
*/
200: EditMessageResponse;
};
export type EditMessageResponse2 = EditMessageResponses[keyof EditMessageResponses];
export type ExportSessionData = { export type ExportSessionData = {
body?: never; body?: never;
path: { path: {
@@ -3290,6 +3249,46 @@ export type GetSessionExtensionsResponses = {
export type GetSessionExtensionsResponse = GetSessionExtensionsResponses[keyof GetSessionExtensionsResponses]; export type GetSessionExtensionsResponse = GetSessionExtensionsResponses[keyof GetSessionExtensionsResponses];
export type ForkSessionData = {
body: ForkRequest;
path: {
/**
* Unique identifier for the session
*/
session_id: string;
};
query?: never;
url: '/sessions/{session_id}/fork';
};
export type ForkSessionErrors = {
/**
* Bad request - truncate=true requires timestamp
*/
400: unknown;
/**
* Unauthorized - Invalid or missing API key
*/
401: unknown;
/**
* Session not found
*/
404: unknown;
/**
* Internal server error
*/
500: unknown;
};
export type ForkSessionResponses = {
/**
* Session forked successfully
*/
200: ForkResponse;
};
export type ForkSessionResponse = ForkSessionResponses[keyof ForkSessionResponses];
export type UpdateSessionNameData = { export type UpdateSessionNameData = {
body: UpdateSessionNameRequest; body: UpdateSessionNameRequest;
path: { path: {
+1 -1
View File
@@ -1,6 +1,6 @@
import { AppEvents } from '../constants/events'; import { AppEvents } from '../constants/events';
import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react'; import React, { useRef, useState, useEffect, useMemo, useCallback } from 'react';
import { Bug, ScrollText, ChefHat } from 'lucide-react'; import { Bug, ChefHat, ScrollText } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip'; import { Tooltip, TooltipContent, TooltipTrigger } from './ui/Tooltip';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { View } from '../utils/navigationUtils'; import type { View } from '../utils/navigationUtils';
@@ -11,6 +11,7 @@ import {
Download, Download,
Upload, Upload,
ExternalLink, ExternalLink,
Copy,
Puzzle, Puzzle,
} from 'lucide-react'; } from 'lucide-react';
import { Card } from '../ui/card'; import { Card } from '../ui/card';
@@ -28,6 +29,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/
import { import {
deleteSession, deleteSession,
exportSession, exportSession,
forkSession,
importSession, importSession,
listSessions, listSessions,
Session, Session,
@@ -436,6 +438,25 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
setShowDeleteConfirmation(true); setShowDeleteConfirmation(true);
}, []); }, []);
const handleDuplicateSession = useCallback(
async (session: Session) => {
try {
await forkSession({
path: { session_id: session.id },
body: { truncate: false, copy: true },
throwOnError: true,
});
toast.success(`Session "${session.name}" duplicated successfully`);
await loadSessions();
} catch (error) {
console.error('Error duplicating session:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
toast.error(`Failed to duplicate session: ${errorMessage}`);
}
},
[loadSessions]
);
const handleConfirmDelete = useCallback(async () => { const handleConfirmDelete = useCallback(async () => {
if (!sessionToDelete) return; if (!sessionToDelete) return;
@@ -530,27 +551,37 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
const SessionItem = React.memo(function SessionItem({ const SessionItem = React.memo(function SessionItem({
session, session,
onEditClick, onEditClick,
onDuplicateClick,
onDeleteClick, onDeleteClick,
onExportClick, onExportClick,
onOpenInNewWindow, onOpenInNewWindow,
}: { }: {
session: Session; session: Session;
onEditClick: (session: Session) => void; onEditClick: (session: Session) => void;
onDuplicateClick: (session: Session) => void;
onDeleteClick: (session: Session) => void; onDeleteClick: (session: Session) => void;
onExportClick: (session: Session, e: React.MouseEvent) => void; onExportClick: (session: Session, e: React.MouseEvent) => void;
onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void; onOpenInNewWindow: (session: Session, e: React.MouseEvent) => void;
}) { }) {
const handleEditClick = useCallback( const handleEditClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click e.stopPropagation();
onEditClick(session); onEditClick(session);
}, },
[onEditClick, session] [onEditClick, session]
); );
const handleDuplicateClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
onDuplicateClick(session);
},
[onDuplicateClick, session]
);
const handleDeleteClick = useCallback( const handleDeleteClick = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
e.stopPropagation(); // Prevent card click e.stopPropagation();
onDeleteClick(session); onDeleteClick(session);
}, },
[onDeleteClick, session] [onDeleteClick, session]
@@ -605,6 +636,13 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
> >
<Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" /> <Edit2 className="w-3 h-3 text-textSubtle hover:text-textStandard" />
</button> </button>
<button
onClick={handleDuplicateClick}
className="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer"
title="Duplicate session"
>
<Copy className="w-3 h-3 text-textSubtle hover:text-textStandard" />
</button>
<button <button
onClick={handleDeleteClick} onClick={handleDeleteClick}
className="p-2 rounded hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors" className="p-2 rounded hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer transition-colors"
@@ -757,6 +795,7 @@ const SessionListView: React.FC<SessionListViewProps> = React.memo(
key={session.id} key={session.id}
session={session} session={session}
onEditClick={handleEditSession} onEditClick={handleEditSession}
onDuplicateClick={handleDuplicateSession}
onDeleteClick={handleDeleteSession} onDeleteClick={handleDeleteSession}
onExportClick={handleExportSession} onExportClick={handleExportSession}
onOpenInNewWindow={handleOpenInNewWindow} onOpenInNewWindow={handleOpenInNewWindow}
+5 -4
View File
@@ -650,27 +650,28 @@ export function useChatStream({
const currentState = stateRef.current; const currentState = stateRef.current;
try { try {
const { editMessage } = await import('../api'); const { forkSession } = await import('../api');
const message = currentState.messages.find((m) => m.id === messageId); const message = currentState.messages.find((m) => m.id === messageId);
if (!message) { if (!message) {
throw new Error(`Message with id ${messageId} not found in current messages`); throw new Error(`Message with id ${messageId} not found in current messages`);
} }
const response = await editMessage({ const response = await forkSession({
path: { path: {
session_id: sessionId, session_id: sessionId,
}, },
body: { body: {
timestamp: message.created, timestamp: message.created,
editType, truncate: true,
copy: editType === 'fork',
}, },
throwOnError: true, throwOnError: true,
}); });
const targetSessionId = response.data?.sessionId; const targetSessionId = response.data?.sessionId;
if (!targetSessionId) { if (!targetSessionId) {
throw new Error('No session ID returned from edit_message'); throw new Error('No session ID returned from fork');
} }
if (editType === 'fork') { if (editType === 'fork') {