From dfc4c52f8f56f8f2d1f4bbec2d3b5fb1be7b1f65 Mon Sep 17 00:00:00 2001 From: harrykamboj1 Date: Sun, 26 Jul 2026 18:33:04 +0530 Subject: [PATCH 1/2] feat(desktop): add markdown format option to session export --- crates/goose-cli/src/commands/session.rs | 104 +- crates/goose-cli/src/session/mod.rs | 2 - crates/goose-sdk-types/src/custom_requests.rs | 35 +- crates/goose/acp-schema.json | 15 +- .../goose/src/acp/server/manage_sessions.rs | 14 +- .../src/session/export_markdown.rs} | 99 +- crates/goose/src/session/mod.rs | 4 + crates/goose/src/session/session_manager.rs | 10 + ui/desktop/src/acp/sessions.ts | 14 +- .../components/sessions/SessionListView.tsx | 1662 +++++++++-------- ui/desktop/src/i18n/messages/de.json | 9 + ui/desktop/src/i18n/messages/en.json | 9 + ui/desktop/src/i18n/messages/es.json | 9 + ui/desktop/src/i18n/messages/fr.json | 9 + ui/desktop/src/i18n/messages/hi.json | 9 + ui/desktop/src/i18n/messages/id.json | 9 + ui/desktop/src/i18n/messages/it.json | 9 + ui/desktop/src/i18n/messages/ja.json | 9 + ui/desktop/src/i18n/messages/ko.json | 9 + ui/desktop/src/i18n/messages/ms.json | 9 + ui/desktop/src/i18n/messages/pt.json | 9 + ui/desktop/src/i18n/messages/ru.json | 9 + ui/desktop/src/i18n/messages/tr.json | 9 + ui/desktop/src/i18n/messages/vi.json | 9 + ui/desktop/src/i18n/messages/zh-CN.json | 9 + ui/desktop/src/i18n/messages/zh-TW.json | 9 + ui/sdk/src/generated/index.ts | 2 +- ui/sdk/src/generated/types.gen.ts | 8 +- ui/sdk/src/generated/zod.gen.ts | 10 +- 29 files changed, 1215 insertions(+), 908 deletions(-) rename crates/{goose-cli/src/session/export.rs => goose/src/session/export_markdown.rs} (92%) diff --git a/crates/goose-cli/src/commands/session.rs b/crates/goose-cli/src/commands/session.rs index 85f7b81d6698..04d2fbfcf4fb 100644 --- a/crates/goose-cli/src/commands/session.rs +++ b/crates/goose-cli/src/commands/session.rs @@ -1,4 +1,3 @@ -use crate::session::user_projected_message_to_markdown; use anyhow::{Context, Result}; use cliclack::{confirm, multiselect, select}; @@ -8,7 +7,8 @@ use goose::config::Config; #[cfg(feature = "nostr")] use goose::session::nostr_share; use goose::session::{ - generate_diagnostics, DiagnosticsLevel, Session, SessionManager, SessionType, + export_session_to_markdown, generate_diagnostics, DiagnosticsLevel, Session, SessionManager, + SessionType, }; use goose::utils::safe_truncate; use regex::Regex; @@ -365,74 +365,6 @@ pub async fn handle_diagnostics(session_id: &str, output_path: Option) Ok(()) } -fn export_session_to_markdown( - messages: Vec, - session_name: &String, -) -> String { - let mut markdown_output = String::new(); - - markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name)); - - if messages.is_empty() { - markdown_output.push_str("*(This session has no messages)*\n"); - return markdown_output; - } - - markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len())); - - // Track if the last message had tool requests to properly handle tool responses - let mut skip_next_if_tool_response = false; - - for message in &messages { - // Check if this is a User message containing only ToolResponses - let is_only_tool_response = message.role == rmcp::model::Role::User - && message.content.iter().all(|content| { - matches!( - content, - goose::conversation::message::MessageContent::ToolResponse(_) - ) - }); - - // If the previous message had tool requests and this one is just tool responses, - // don't create a new User section - we'll attach the responses to the tool calls - if skip_next_if_tool_response && is_only_tool_response { - // Export the tool responses without a User heading - markdown_output.push_str(&user_projected_message_to_markdown(message)); - markdown_output.push_str("\n\n---\n\n"); - skip_next_if_tool_response = false; - continue; - } - - // Reset the skip flag - we'll update it below if needed - skip_next_if_tool_response = false; - - // Output the role prefix except for tool response-only messages - if !is_only_tool_response { - let role_prefix = match message.role { - rmcp::model::Role::User => "### User:\n", - rmcp::model::Role::Assistant => "### Assistant:\n", - }; - markdown_output.push_str(role_prefix); - } - - // Add the message content - markdown_output.push_str(&user_projected_message_to_markdown(message)); - markdown_output.push_str("\n\n---\n\n"); - - // Check if this message has any tool requests, to handle the next message differently - if message.content.iter().any(|content| { - matches!( - content, - goose::conversation::message::MessageContent::ToolRequest(_) - ) - }) { - skip_next_if_tool_response = true; - } - } - - markdown_output -} - /// Prompt the user to interactively select a session /// /// Shows a list of available sessions and lets the user select one @@ -487,35 +419,3 @@ pub async fn prompt_interactive_session_selection( Err(anyhow::anyhow!("Invalid selection")) } } - -#[cfg(test)] -mod tests { - use super::*; - use goose::conversation::message::Message; - use goose::conversation::Conversation; - use rmcp::model::{Content, Role}; - - #[test] - fn markdown_export_preserves_user_audience_tool_output() { - let user_output = Content::text("user-visible output").with_audience(vec![Role::User]); - let assistant_output = - Content::text("assistant-only output").with_audience(vec![Role::Assistant]); - let conversation = Conversation::new_unvalidated([Message::user().with_tool_response( - "tool-1", - Ok(rmcp::model::CallToolResult::success(vec![ - user_output, - assistant_output, - Content::text("shared output"), - ])), - )]); - - let markdown = export_session_to_markdown( - conversation.user_visible_messages(), - &"Audience export".to_string(), - ); - - assert!(markdown.contains("user-visible output")); - assert!(markdown.contains("shared output")); - assert!(!markdown.contains("assistant-only output")); - } -} diff --git a/crates/goose-cli/src/session/mod.rs b/crates/goose-cli/src/session/mod.rs index c626aa695104..507cdeb758f9 100644 --- a/crates/goose-cli/src/session/mod.rs +++ b/crates/goose-cli/src/session/mod.rs @@ -2,7 +2,6 @@ mod builder; mod completion; pub mod editor; mod elicitation; -mod export; mod input; mod output; mod paste; @@ -20,7 +19,6 @@ use std::str::FromStr; use tokio::signal::ctrl_c; use tokio_util::task::AbortOnDropHandle; -pub use self::export::{message_to_markdown, user_projected_message_to_markdown}; pub use builder::{build_session, SessionBuilderConfig}; use console::Color; use goose::agents::AgentEvent; diff --git a/crates/goose-sdk-types/src/custom_requests.rs b/crates/goose-sdk-types/src/custom_requests.rs index a4502ca25578..9c4c0a6688e5 100644 --- a/crates/goose-sdk-types/src/custom_requests.rs +++ b/crates/goose-sdk-types/src/custom_requests.rs @@ -758,15 +758,26 @@ pub struct UnarchiveSessionRequest { pub session_id: String, } -/// Export a session as a JSON string. +/// Export a session as a JSON or markdown string. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)] #[request(method = "_goose/unstable/session/export", response = ExportSessionResponse)] #[serde(rename_all = "camelCase")] pub struct ExportSessionRequest { pub session_id: String, + #[serde(default)] + pub format: SessionExportFormat, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SessionExportFormat { + #[default] + Json, + Markdown, } -/// Export session response — raw JSON of the goose session with `conversation`. +/// Export session response — raw JSON of the goose session with `conversation`, +/// or a markdown transcript when `format` is `markdown`. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct ExportSessionResponse { pub data: String, @@ -2262,3 +2273,23 @@ pub struct SetToolPermissionsRequest { #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)] pub struct SetToolPermissionsResponse {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn export_session_request_defaults_to_json_without_format() { + let req: ExportSessionRequest = serde_json::from_str(r#"{"sessionId":"abc"}"#).unwrap(); + + assert_eq!(req.format, SessionExportFormat::Json); + } + + #[test] + fn export_session_request_accepts_markdown_format() { + let req: ExportSessionRequest = + serde_json::from_str(r#"{"sessionId":"abc","format":"markdown"}"#).unwrap(); + + assert_eq!(req.format, SessionExportFormat::Markdown); + } +} diff --git a/crates/goose/acp-schema.json b/crates/goose/acp-schema.json index e24c4610f59c..a99397e5dab2 100644 --- a/crates/goose/acp-schema.json +++ b/crates/goose/acp-schema.json @@ -3409,15 +3409,26 @@ "properties": { "sessionId": { "type": "string" + }, + "format": { + "$ref": "#/$defs/SessionExportFormat", + "default": "json" } }, "required": [ "sessionId" ], - "description": "Export a session as a JSON string.", + "description": "Export a session as a JSON or markdown string.", "x-side": "agent", "x-method": "_goose/unstable/session/export" }, + "SessionExportFormat": { + "type": "string", + "enum": [ + "json", + "markdown" + ] + }, "ExportSessionResponse_unstable": { "type": "object", "properties": { @@ -3428,7 +3439,7 @@ "required": [ "data" ], - "description": "Export session response — raw JSON of the goose session with `conversation`.", + "description": "Export session response — raw JSON of the goose session with `conversation`,\nor a markdown transcript when `format` is `markdown`.", "x-side": "agent", "x-method": "_goose/unstable/session/export" }, diff --git a/crates/goose/src/acp/server/manage_sessions.rs b/crates/goose/src/acp/server/manage_sessions.rs index 3c79da1e2c8f..cde2422645ce 100644 --- a/crates/goose/src/acp/server/manage_sessions.rs +++ b/crates/goose/src/acp/server/manage_sessions.rs @@ -115,11 +115,15 @@ impl GooseAcpAgent { &self, req: ExportSessionRequest, ) -> Result { - let data = self - .session_manager - .export_session(&req.session_id) - .await - .internal_err()?; + let data = match req.format { + SessionExportFormat::Json => self.session_manager.export_session(&req.session_id).await, + SessionExportFormat::Markdown => { + self.session_manager + .export_session_markdown(&req.session_id) + .await + } + } + .internal_err()?; Ok(ExportSessionResponse { data }) } diff --git a/crates/goose-cli/src/session/export.rs b/crates/goose/src/session/export_markdown.rs similarity index 92% rename from crates/goose-cli/src/session/export.rs rename to crates/goose/src/session/export_markdown.rs index 8fd6a284175a..7e4f3cf25a2d 100644 --- a/crates/goose-cli/src/session/export.rs +++ b/crates/goose/src/session/export_markdown.rs @@ -1,7 +1,7 @@ -use goose::conversation::message::{ +use crate::conversation::message::{ ActionRequiredData, Message, MessageContent, ToolNameParts, ToolRequest, ToolResponse, }; -use goose::utils::safe_truncate; +use crate::utils::safe_truncate; use rmcp::model::{RawContent, ResourceContents, Role}; use serde_json::Value; @@ -426,10 +426,73 @@ fn message_to_markdown_for_audience( md.trim_end_matches("\n").to_string() } +pub fn export_session_to_markdown(messages: Vec, session_name: &str) -> String { + let mut markdown_output = String::new(); + + markdown_output.push_str(&format!("# Session Export: {}\n\n", session_name)); + + if messages.is_empty() { + markdown_output.push_str("*(This session has no messages)*\n"); + return markdown_output; + } + + markdown_output.push_str(&format!("*Total messages: {}*\n\n---\n\n", messages.len())); + + // Track if the last message had tool requests to properly handle tool responses + let mut skip_next_if_tool_response = false; + + for message in &messages { + // Check if this is a User message containing only ToolResponses + let is_only_tool_response = message.role == Role::User + && message + .content + .iter() + .all(|content| matches!(content, MessageContent::ToolResponse(_))); + + // If the previous message had tool requests and this one is just tool responses, + // don't create a new User section - we'll attach the responses to the tool calls + if skip_next_if_tool_response && is_only_tool_response { + // Export the tool responses without a User heading + markdown_output.push_str(&user_projected_message_to_markdown(message)); + markdown_output.push_str("\n\n---\n\n"); + skip_next_if_tool_response = false; + continue; + } + + // Reset the skip flag - we'll update it below if needed + skip_next_if_tool_response = false; + + // Output the role prefix except for tool response-only messages + if !is_only_tool_response { + let role_prefix = match message.role { + Role::User => "### User:\n", + Role::Assistant => "### Assistant:\n", + }; + markdown_output.push_str(role_prefix); + } + + // Add the message content + markdown_output.push_str(&user_projected_message_to_markdown(message)); + markdown_output.push_str("\n\n---\n\n"); + + // Check if this message has any tool requests, to handle the next message differently + if message + .content + .iter() + .any(|content| matches!(content, MessageContent::ToolRequest(_))) + { + skip_next_if_tool_response = true; + } + } + + markdown_output +} + #[cfg(test)] mod tests { use super::*; - use goose::conversation::message::{Message, ToolRequest, ToolResponse}; + use crate::conversation::message::{Message, ToolRequest, ToolResponse}; + use crate::conversation::Conversation; use rmcp::model::{CallToolRequestParams, Content, RawTextContent, TextContent}; use rmcp::object; use serde_json::json; @@ -1197,4 +1260,34 @@ found 0 vulnerabilities"#; assert!(response_result.contains("added 57 packages")); assert!(response_result.contains("found 0 vulnerabilities")); } + + #[test] + fn markdown_export_preserves_user_audience_tool_output() { + let user_output = Content::text("user-visible output").with_audience(vec![Role::User]); + let assistant_output = + Content::text("assistant-only output").with_audience(vec![Role::Assistant]); + let conversation = Conversation::new_unvalidated([Message::user().with_tool_response( + "tool-1", + Ok(rmcp::model::CallToolResult::success(vec![ + user_output, + assistant_output, + Content::text("shared output"), + ])), + )]); + + let markdown = + export_session_to_markdown(conversation.user_visible_messages(), "Audience export"); + + assert!(markdown.contains("user-visible output")); + assert!(markdown.contains("shared output")); + assert!(!markdown.contains("assistant-only output")); + } + + #[test] + fn markdown_export_handles_empty_conversation() { + let markdown = export_session_to_markdown(Vec::new(), "Empty session"); + + assert!(markdown.contains("# Session Export: Empty session")); + assert!(markdown.contains("*(This session has no messages)*")); + } } diff --git a/crates/goose/src/session/mod.rs b/crates/goose/src/session/mod.rs index 57b16ca39ba8..edbd3c577166 100644 --- a/crates/goose/src/session/mod.rs +++ b/crates/goose/src/session/mod.rs @@ -1,5 +1,6 @@ mod chat_history_search; mod diagnostics; +mod export_markdown; pub mod extension_data; pub mod import_formats; mod last_message_snippet; @@ -15,6 +16,9 @@ pub use diagnostics::{ DiagnosticsLevel, DiagnosticsLogs, DiagnosticsPrompt, DiagnosticsReport, DiagnosticsScheduledRecipe, DiagnosticsTextFile, SystemInfo, }; +pub use export_markdown::{ + export_session_to_markdown, message_to_markdown, user_projected_message_to_markdown, +}; pub use extension_data::{EnabledExtensionsState, ExtensionData, ExtensionState, TodoState}; pub use session_manager::{ Session, SessionInsights, SessionManager, SessionNameUpdate, SessionType, SessionUpdateBuilder, diff --git a/crates/goose/src/session/session_manager.rs b/crates/goose/src/session/session_manager.rs index 48b567c9a9ab..5c789bfc5d33 100644 --- a/crates/goose/src/session/session_manager.rs +++ b/crates/goose/src/session/session_manager.rs @@ -5,6 +5,7 @@ use crate::conversation::Conversation; use crate::providers::base::CostSource; use crate::providers::base::Provider; use crate::recipe::Recipe; +use crate::session::export_markdown::export_session_to_markdown; use crate::session::extension_data::ExtensionData; use crate::session::session_naming::{ generate_session_name, MSG_COUNT_FOR_SESSION_NAME_GENERATION, @@ -489,6 +490,15 @@ impl SessionManager { self.storage.export_session(id).await } + pub async fn export_session_markdown(&self, id: &str) -> Result { + let session = self.get_session(id, true).await?; + let messages = session + .conversation + .map(|conversation| conversation.user_visible_messages()) + .unwrap_or_default(); + Ok(export_session_to_markdown(messages, &session.name)) + } + pub async fn import_session( &self, json: &str, diff --git a/ui/desktop/src/acp/sessions.ts b/ui/desktop/src/acp/sessions.ts index dc1c18321ec4..3c08c10960f9 100644 --- a/ui/desktop/src/acp/sessions.ts +++ b/ui/desktop/src/acp/sessions.ts @@ -5,7 +5,7 @@ import type { NewSessionRequest, SessionInfo, } from '@agentclientprotocol/sdk'; -import type { GooseExtension, SessionImportSource } from '@aaif/goose-sdk'; +import type { GooseExtension, SessionExportFormat, SessionImportSource } from '@aaif/goose-sdk'; import { getAcpClient } from './acpConnection'; import { DEFAULT_CHAT_TITLE } from '../contexts/ChatContext'; import type { ExtensionLoadResult } from '../types/extensions'; @@ -296,16 +296,16 @@ export async function acpForkSession( return String(response.sessionId); } -export async function acpExportSession(sessionId: string): Promise { +export async function acpExportSession( + sessionId: string, + format: SessionExportFormat = 'json' +): Promise { const client = await getAcpClient(); - const response = await client.goose.sessionExport_unstable({ sessionId }); + const response = await client.goose.sessionExport_unstable({ sessionId, format }); return response.data; } -export async function acpImportSession( - input: string, - source: SessionImportSource -): Promise { +export async function acpImportSession(input: string, source: SessionImportSource): Promise { const client = await getAcpClient(); await client.goose.sessionImport_unstable({ input, source }); } diff --git a/ui/desktop/src/components/sessions/SessionListView.tsx b/ui/desktop/src/components/sessions/SessionListView.tsx index c6c08e5e2ef0..118e53230d26 100644 --- a/ui/desktop/src/components/sessions/SessionListView.tsx +++ b/ui/desktop/src/components/sessions/SessionListView.tsx @@ -34,6 +34,12 @@ import { DialogHeader, DialogTitle, } from '../ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '../ui/dropdown-menu'; import { acpDeleteSession, acpExportSession, @@ -44,6 +50,7 @@ import { acpShareSessionNostr, type SessionListItem, } from '../../acp/sessions'; +import type { SessionExportFormat } from '@aaif/goose-sdk'; import { acpChatSessionActions } from '../../acp/chatSessionStore'; import { cancelAcpPermissionRequestsForSession } from '../../acp/permissionRequests'; import { cancelAcpElicitationRequestsForSession } from '../../acp/elicitationRequests'; @@ -51,48 +58,110 @@ import { getSearchShortcutText } from '../../utils/keyboardShortcuts'; const i18n = defineMessages({ editSessionTitle: { id: 'sessions.edit.title', defaultMessage: 'Edit Session Description' }, - editSessionPlaceholder: { id: 'sessions.edit.placeholder', defaultMessage: 'Enter session description' }, + editSessionPlaceholder: { + id: 'sessions.edit.placeholder', + defaultMessage: 'Enter session description', + }, cancel: { id: 'sessions.cancel', defaultMessage: 'Cancel' }, save: { id: 'sessions.save', defaultMessage: 'Save' }, saving: { id: 'sessions.saving', defaultMessage: 'Saving...' }, - sessionUpdated: { id: 'sessions.toast.updated', defaultMessage: 'Session description updated successfully' }, - sessionUpdateFailed: { id: 'sessions.toast.updateFailed', defaultMessage: 'Failed to update session description: {error}' }, + sessionUpdated: { + id: 'sessions.toast.updated', + defaultMessage: 'Session description updated successfully', + }, + sessionUpdateFailed: { + id: 'sessions.toast.updateFailed', + defaultMessage: 'Failed to update session description: {error}', + }, chatHistory: { id: 'sessions.chatHistory', defaultMessage: 'Chat history' }, importSession: { id: 'sessions.import', defaultMessage: 'Import Session' }, importNostrSession: { id: 'sessions.importNostr', defaultMessage: 'Import Link' }, importNostrTitle: { id: 'sessions.importNostr.title', defaultMessage: 'Import Nostr Session' }, - importNostrDesc: { id: 'sessions.importNostr.description', defaultMessage: 'Paste a Goose Nostr share link to fetch, decrypt, and import the session.' }, - importNostrPlaceholder: { id: 'sessions.importNostr.placeholder', defaultMessage: 'goose://sessions/nostr?nevent=...&key=...' }, + importNostrDesc: { + id: 'sessions.importNostr.description', + defaultMessage: 'Paste a Goose Nostr share link to fetch, decrypt, and import the session.', + }, + importNostrPlaceholder: { + id: 'sessions.importNostr.placeholder', + defaultMessage: 'goose://sessions/nostr?nevent=...&key=...', + }, importing: { id: 'sessions.importing', defaultMessage: 'Importing...' }, - chatHistoryDesc: { id: 'sessions.chatHistoryDesc', defaultMessage: 'View and search your past conversations with Goose. {shortcut} to search.' }, + chatHistoryDesc: { + id: 'sessions.chatHistoryDesc', + defaultMessage: 'View and search your past conversations with Goose. {shortcut} to search.', + }, searchPlaceholder: { id: 'sessions.searchPlaceholder', defaultMessage: 'Search history...' }, errorLoading: { id: 'sessions.error.loading', defaultMessage: 'Error Loading Sessions' }, tryAgain: { id: 'sessions.error.tryAgain', defaultMessage: 'Try Again' }, noSessions: { id: 'sessions.empty.title', defaultMessage: 'No chat sessions found' }, - noSessionsDesc: { id: 'sessions.empty.description', defaultMessage: 'Your chat history will appear here' }, + noSessionsDesc: { + id: 'sessions.empty.description', + defaultMessage: 'Your chat history will appear here', + }, noMatching: { id: 'sessions.search.noResults', defaultMessage: 'No matching sessions found' }, - noMatchingDesc: { id: 'sessions.search.noResultsDesc', defaultMessage: 'Try adjusting your search terms' }, + noMatchingDesc: { + id: 'sessions.search.noResultsDesc', + defaultMessage: 'Try adjusting your search terms', + }, loadingMore: { id: 'sessions.loadingMore', defaultMessage: 'Loading more sessions...' }, deleteTitle: { id: 'sessions.delete.title', defaultMessage: 'Delete Session' }, - deleteMessage: { id: 'sessions.delete.message', defaultMessage: 'Are you sure you want to delete the session "{name}"? This action cannot be undone.' }, - duplicateSuccess: { id: 'sessions.toast.duplicated', defaultMessage: 'Session "{name}" duplicated successfully' }, - duplicateFailed: { id: 'sessions.toast.duplicateFailed', defaultMessage: 'Failed to duplicate session: {error}' }, + deleteMessage: { + id: 'sessions.delete.message', + defaultMessage: + 'Are you sure you want to delete the session "{name}"? This action cannot be undone.', + }, + duplicateSuccess: { + id: 'sessions.toast.duplicated', + defaultMessage: 'Session "{name}" duplicated successfully', + }, + duplicateFailed: { + id: 'sessions.toast.duplicateFailed', + defaultMessage: 'Failed to duplicate session: {error}', + }, deleteSuccess: { id: 'sessions.toast.deleted', defaultMessage: 'Session deleted successfully' }, - deleteFailed: { id: 'sessions.toast.deleteFailed', defaultMessage: 'Failed to delete session "{name}": {error}' }, + deleteFailed: { + id: 'sessions.toast.deleteFailed', + defaultMessage: 'Failed to delete session "{name}": {error}', + }, importSuccess: { id: 'sessions.toast.imported', defaultMessage: 'Session imported successfully' }, - importFailed: { id: 'sessions.toast.importFailed', defaultMessage: 'Failed to import session: {error}' }, + importFailed: { + id: 'sessions.toast.importFailed', + defaultMessage: 'Failed to import session: {error}', + }, exportSuccess: { id: 'sessions.toast.exported', defaultMessage: 'Session exported successfully' }, - shareNostrSuccess: { id: 'sessions.toast.shareNostr', defaultMessage: 'Encrypted Nostr share link created' }, - shareNostrFailed: { id: 'sessions.toast.shareNostrFailed', defaultMessage: 'Failed to create Nostr share link: {error}' }, + exportFailed: { + id: 'sessions.toast.exportFailed', + defaultMessage: 'Failed to export session: {error}', + }, + shareNostrSuccess: { + id: 'sessions.toast.shareNostr', + defaultMessage: 'Encrypted Nostr share link created', + }, + shareNostrFailed: { + id: 'sessions.toast.shareNostrFailed', + defaultMessage: 'Failed to create Nostr share link: {error}', + }, copied: { id: 'sessions.toast.copied', defaultMessage: 'Copied to clipboard' }, openInNewWindow: { id: 'sessions.action.openNewWindow', defaultMessage: 'Open in new window' }, editSessionName: { id: 'sessions.action.editName', defaultMessage: 'Edit session name' }, duplicateSession: { id: 'sessions.action.duplicate', defaultMessage: 'Duplicate session' }, deleteSession: { id: 'sessions.action.delete', defaultMessage: 'Delete session' }, exportSession: { id: 'sessions.action.export', defaultMessage: 'Export session' }, - shareNostrSession: { id: 'sessions.action.shareNostr', defaultMessage: 'Share encrypted Nostr link' }, - shareNostrTitle: { id: 'sessions.shareNostr.title', defaultMessage: 'Encrypted Nostr Share Link' }, - shareNostrDesc: { id: 'sessions.shareNostr.description', defaultMessage: 'Anyone with this link can fetch and decrypt the session. Treat it like a secret.' }, + exportAsJson: { id: 'sessions.action.exportJson', defaultMessage: 'JSON' }, + exportAsMarkdown: { id: 'sessions.action.exportMarkdown', defaultMessage: 'Markdown' }, + shareNostrSession: { + id: 'sessions.action.shareNostr', + defaultMessage: 'Share encrypted Nostr link', + }, + shareNostrTitle: { + id: 'sessions.shareNostr.title', + defaultMessage: 'Encrypted Nostr Share Link', + }, + shareNostrDesc: { + id: 'sessions.shareNostr.description', + defaultMessage: + 'Anyone with this link can fetch and decrypt the session. Treat it like a secret.', + }, close: { id: 'sessions.close', defaultMessage: 'Close' }, }); @@ -172,7 +241,9 @@ const EditSessionModal = React.memo( return (
-

{intl.formatMessage(i18n.editSessionTitle)}

+

+ {intl.formatMessage(i18n.editSessionTitle)} +

@@ -232,868 +303,901 @@ interface SessionListViewProps { onSelectSession: (sessionId: string) => void; } -const SessionListView: React.FC = React.memo( - ({ onSelectSession }) => { - const intl = useIntl(); - const [sessions, setSessions] = useState([]); - const [isPrefetchingSessions, setIsPrefetchingSessions] = useState(false); - const [dateGroups, setDateGroups] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showSkeleton, setShowSkeleton] = useState(true); - const [showContent, setShowContent] = useState(false); - const [error, setError] = useState(null); - - const [visibleGroupsCount, setVisibleGroupsCount] = useState(15); - - // Edit modal state - const [showEditModal, setShowEditModal] = useState(false); - const [editingSession, setEditingSession] = useState(null); - - // Delete confirmation modal state - const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); - const [sessionToDelete, setSessionToDelete] = useState(null); - - const [showImportLinkModal, setShowImportLinkModal] = useState(false); - const [nostrImportLink, setNostrImportLink] = useState(''); - const [isImportingNostr, setIsImportingNostr] = useState(false); - const [shareLink, setShareLink] = useState(''); - const [showShareLinkModal, setShowShareLinkModal] = useState(false); - const [sharingSessionId, setSharingSessionId] = useState(null); - const [nostrEnabled, setNostrEnabled] = useState(true); - - // Search state for debouncing - const [searchTerm, setSearchTerm] = useState(''); - const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce - const debouncedSearchTermRef = useRef(debouncedSearchTerm); - debouncedSearchTermRef.current = debouncedSearchTerm; - - const containerRef = useRef(null); - const loadGenerationRef = useRef(0); - const hasLoadedRef = useRef(false); - - const fileInputRef = useRef(null); - - const visibleDateGroups = useMemo(() => { - return dateGroups.slice(0, visibleGroupsCount); - }, [dateGroups, visibleGroupsCount]); - - const previousSearchTermRef = useRef(''); - useEffect(() => { - const wasSearching = previousSearchTermRef.current.length > 0; - const isSearching = debouncedSearchTerm.length > 0; - previousSearchTermRef.current = debouncedSearchTerm; - - if (isSearching) { - setVisibleGroupsCount(dateGroups.length); - } else if (wasSearching) { - setVisibleGroupsCount(15); - } - }, [debouncedSearchTerm, dateGroups.length]); - - const loadRemainingSessionPages = useCallback( - async (initialCursor: string, loadId: number, keyword?: string) => { - let cursor: string | null = initialCursor; - setIsPrefetchingSessions(true); - - try { - while (cursor && loadGenerationRef.current === loadId) { - const resp = await acpListSessions(cursor, { keyword }); - if (loadGenerationRef.current !== loadId) return; - - cursor = resp.nextCursor; - startTransition(() => { - setSessions((prev) => { - const seen = new Set(prev.map((s) => s.id)); - return [...prev, ...resp.sessions.filter((s) => !seen.has(s.id))]; - }); - }); - } - } catch (err) { - console.error('Failed to load remaining sessions:', err); - } finally { - if (loadGenerationRef.current === loadId) { - setIsPrefetchingSessions(false); - } - } - }, - [] - ); +const SessionListView: React.FC = React.memo(({ onSelectSession }) => { + const intl = useIntl(); + const [sessions, setSessions] = useState([]); + const [isPrefetchingSessions, setIsPrefetchingSessions] = useState(false); + const [dateGroups, setDateGroups] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [showSkeleton, setShowSkeleton] = useState(true); + const [showContent, setShowContent] = useState(false); + const [error, setError] = useState(null); + + const [visibleGroupsCount, setVisibleGroupsCount] = useState(15); + + // Edit modal state + const [showEditModal, setShowEditModal] = useState(false); + const [editingSession, setEditingSession] = useState(null); + + // Delete confirmation modal state + const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false); + const [sessionToDelete, setSessionToDelete] = useState(null); + + const [showImportLinkModal, setShowImportLinkModal] = useState(false); + const [nostrImportLink, setNostrImportLink] = useState(''); + const [isImportingNostr, setIsImportingNostr] = useState(false); + const [shareLink, setShareLink] = useState(''); + const [showShareLinkModal, setShowShareLinkModal] = useState(false); + const [sharingSessionId, setSharingSessionId] = useState(null); + const [nostrEnabled, setNostrEnabled] = useState(true); + + // Search state for debouncing + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounce(searchTerm, 300); // 300ms debounce + const debouncedSearchTermRef = useRef(debouncedSearchTerm); + debouncedSearchTermRef.current = debouncedSearchTerm; + + const containerRef = useRef(null); + const loadGenerationRef = useRef(0); + const hasLoadedRef = useRef(false); + + const fileInputRef = useRef(null); + + const visibleDateGroups = useMemo(() => { + return dateGroups.slice(0, visibleGroupsCount); + }, [dateGroups, visibleGroupsCount]); + + const previousSearchTermRef = useRef(''); + useEffect(() => { + const wasSearching = previousSearchTermRef.current.length > 0; + const isSearching = debouncedSearchTerm.length > 0; + previousSearchTermRef.current = debouncedSearchTerm; + + if (isSearching) { + setVisibleGroupsCount(dateGroups.length); + } else if (wasSearching) { + setVisibleGroupsCount(15); + } + }, [debouncedSearchTerm, dateGroups.length]); + + const loadRemainingSessionPages = useCallback( + async (initialCursor: string, loadId: number, keyword?: string) => { + let cursor: string | null = initialCursor; + setIsPrefetchingSessions(true); - const loadSessions = useCallback( - async (keyword: string = debouncedSearchTermRef.current) => { - const loadId = loadGenerationRef.current + 1; - loadGenerationRef.current = loadId; - // Only show the skeleton on the first load; subsequent loads (e.g. typing a - // search keyword) update the list in place without flashing the skeleton. - const isFirstLoad = !hasLoadedRef.current; - setIsPrefetchingSessions(false); - setError(null); - if (isFirstLoad) { - setIsLoading(true); - setShowSkeleton(true); - setShowContent(false); - } - try { - const resp = await acpListSessions(undefined, { keyword }); + try { + while (cursor && loadGenerationRef.current === loadId) { + const resp = await acpListSessions(cursor, { keyword }); if (loadGenerationRef.current !== loadId) return; - hasLoadedRef.current = true; + cursor = resp.nextCursor; startTransition(() => { - setSessions(resp.sessions); + setSessions((prev) => { + const seen = new Set(prev.map((s) => s.id)); + return [...prev, ...resp.sessions.filter((s) => !seen.has(s.id))]; + }); }); - - if (resp.nextCursor) { - void loadRemainingSessionPages(resp.nextCursor, loadId, keyword); - } - } catch (err) { - if (loadGenerationRef.current !== loadId) return; - - console.error('Failed to load sessions:', err); - setError('Failed to load sessions. Please try again later.'); - setSessions([]); - } finally { - if (loadGenerationRef.current === loadId && isFirstLoad) { - setIsLoading(false); - } } - }, - [loadRemainingSessionPages] - ); - - const handleScroll = useCallback( - (target: HTMLDivElement) => { - const { scrollTop, scrollHeight, clientHeight } = target; - const threshold = 200; - - if (scrollHeight - scrollTop - clientHeight >= threshold) return; - - if (visibleGroupsCount < dateGroups.length) { - setVisibleGroupsCount((prev) => Math.min(prev + 5, dateGroups.length)); + } catch (err) { + console.error('Failed to load remaining sessions:', err); + } finally { + if (loadGenerationRef.current === loadId) { + setIsPrefetchingSessions(false); } - }, - [visibleGroupsCount, dateGroups.length] - ); - - useEffect(() => { - loadSessions(debouncedSearchTerm); - return () => { - // Bump the generation so any in-flight load for the previous keyword is discarded. - loadGenerationRef.current += 1; - }; - }, [loadSessions, debouncedSearchTerm]); - - // Hide Nostr sharing when explicitly disabled via env var (restricted/enterprise bundles) - useEffect(() => { - const config = window.electron.getConfig(); - if (config.GOOSE_DISABLE_NOSTR_SHARING === true) { - setNostrEnabled(false); } - }, []); + }, + [] + ); + + const loadSessions = useCallback( + async (keyword: string = debouncedSearchTermRef.current) => { + const loadId = loadGenerationRef.current + 1; + loadGenerationRef.current = loadId; + // Only show the skeleton on the first load; subsequent loads (e.g. typing a + // search keyword) update the list in place without flashing the skeleton. + const isFirstLoad = !hasLoadedRef.current; + setIsPrefetchingSessions(false); + setError(null); + if (isFirstLoad) { + setIsLoading(true); + setShowSkeleton(true); + setShowContent(false); + } + try { + const resp = await acpListSessions(undefined, { keyword }); + if (loadGenerationRef.current !== loadId) return; + hasLoadedRef.current = true; - // Timing logic to prevent flicker between skeleton and content on initial load - useEffect(() => { - if (!isLoading && showSkeleton) { - setShowSkeleton(false); - // Use startTransition for non-blocking content show startTransition(() => { - setTimeout(() => { - setShowContent(true); - }, 10); + setSessions(resp.sessions); }); - } - return () => void 0; - }, [isLoading, showSkeleton]); - // Memoize date groups calculation to prevent unnecessary recalculations - const memoizedDateGroups = useMemo(() => { - if (sessions.length > 0) { - return groupSessionsByDate(sessions); - } - return []; - }, [sessions]); + if (resp.nextCursor) { + void loadRemainingSessionPages(resp.nextCursor, loadId, keyword); + } + } catch (err) { + if (loadGenerationRef.current !== loadId) return; - // Update date groups when filtered sessions change - useEffect(() => { - startTransition(() => { - setDateGroups(memoizedDateGroups); - }); - }, [memoizedDateGroups]); + console.error('Failed to load sessions:', err); + setError('Failed to load sessions. Please try again later.'); + setSessions([]); + } finally { + if (loadGenerationRef.current === loadId && isFirstLoad) { + setIsLoading(false); + } + } + }, + [loadRemainingSessionPages] + ); - // Handle immediate search input (updates search term for debouncing). - const handleSearch = useCallback((term: string) => { - setSearchTerm(term); - }, []); + const handleScroll = useCallback( + (target: HTMLDivElement) => { + const { scrollTop, scrollHeight, clientHeight } = target; + const threshold = 200; - // Handle modal close - const handleModalClose = useCallback(() => { - setShowEditModal(false); - setEditingSession(null); - }, []); + if (scrollHeight - scrollTop - clientHeight >= threshold) return; - const handleModalSave = useCallback(async (sessionId: string, newDescription: string) => { - // Update state immediately for optimistic UI - setSessions((prevSessions) => - prevSessions.map((s) => - s.id === sessionId ? { ...s, name: newDescription, user_set_name: true } : s - ) - ); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_RENAMED, { - detail: { sessionId, newName: newDescription, userInitiated: true }, - }) - ); - }, []); + if (visibleGroupsCount < dateGroups.length) { + setVisibleGroupsCount((prev) => Math.min(prev + 5, dateGroups.length)); + } + }, + [visibleGroupsCount, dateGroups.length] + ); - const handleEditSession = useCallback((session: SessionListItem) => { - setEditingSession(session); - setShowEditModal(true); - }, []); + useEffect(() => { + loadSessions(debouncedSearchTerm); + return () => { + // Bump the generation so any in-flight load for the previous keyword is discarded. + loadGenerationRef.current += 1; + }; + }, [loadSessions, debouncedSearchTerm]); - const handleDeleteSession = useCallback((session: SessionListItem) => { - setSessionToDelete(session); - setShowDeleteConfirmation(true); - }, []); + // Hide Nostr sharing when explicitly disabled via env var (restricted/enterprise bundles) + useEffect(() => { + const config = window.electron.getConfig(); + if (config.GOOSE_DISABLE_NOSTR_SHARING === true) { + setNostrEnabled(false); + } + }, []); - const handleDuplicateSession = useCallback( - async (session: SessionListItem) => { - try { - await acpForkSession(session.id); - toast.success(intl.formatMessage(i18n.duplicateSuccess, { name: session.name })); - window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - await loadSessions(); - } catch (error) { - console.error('Error duplicating session:', error); - toast.error(intl.formatMessage(i18n.duplicateFailed, { error: errorMessage(error, 'Unknown error') })); - } - }, - [loadSessions, intl] + // Timing logic to prevent flicker between skeleton and content on initial load + useEffect(() => { + if (!isLoading && showSkeleton) { + setShowSkeleton(false); + // Use startTransition for non-blocking content show + startTransition(() => { + setTimeout(() => { + setShowContent(true); + }, 10); + }); + } + return () => void 0; + }, [isLoading, showSkeleton]); + + // Memoize date groups calculation to prevent unnecessary recalculations + const memoizedDateGroups = useMemo(() => { + if (sessions.length > 0) { + return groupSessionsByDate(sessions); + } + return []; + }, [sessions]); + + // Update date groups when filtered sessions change + useEffect(() => { + startTransition(() => { + setDateGroups(memoizedDateGroups); + }); + }, [memoizedDateGroups]); + + // Handle immediate search input (updates search term for debouncing). + const handleSearch = useCallback((term: string) => { + setSearchTerm(term); + }, []); + + // Handle modal close + 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, name: newDescription, user_set_name: true } : s + ) + ); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_RENAMED, { + detail: { sessionId, newName: newDescription, userInitiated: true }, + }) ); + }, []); - const handleConfirmDelete = useCallback(async () => { - if (!sessionToDelete) return; + const handleEditSession = useCallback((session: SessionListItem) => { + setEditingSession(session); + setShowEditModal(true); + }, []); - setShowDeleteConfirmation(false); - const sessionToDeleteId = sessionToDelete.id; - const sessionName = sessionToDelete.name; - setSessionToDelete(null); + const handleDeleteSession = useCallback((session: SessionListItem) => { + setSessionToDelete(session); + setShowDeleteConfirmation(true); + }, []); + const handleDuplicateSession = useCallback( + async (session: SessionListItem) => { try { - await acpDeleteSession(sessionToDeleteId); - toast.success(intl.formatMessage(i18n.deleteSuccess)); - window.dispatchEvent( - new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } }) - ); - cancelAcpPermissionRequestsForSession(sessionToDeleteId); - cancelAcpElicitationRequestsForSession(sessionToDeleteId); - acpChatSessionActions.deleteSnapshot(sessionToDeleteId); + await acpForkSession(session.id); + toast.success(intl.formatMessage(i18n.duplicateSuccess, { name: session.name })); + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); + await loadSessions(); } catch (error) { - console.error('Error deleting session:', error); - toast.error(intl.formatMessage(i18n.deleteFailed, { name: sessionName, error: errorMessage(error, 'Unknown error') })); + console.error('Error duplicating session:', error); + toast.error( + intl.formatMessage(i18n.duplicateFailed, { error: errorMessage(error, 'Unknown error') }) + ); } - await loadSessions(); - }, [sessionToDelete, loadSessions, intl]); + }, + [loadSessions, intl] + ); - const handleCancelDelete = useCallback(() => { - setShowDeleteConfirmation(false); - setSessionToDelete(null); - }, []); + const handleConfirmDelete = useCallback(async () => { + if (!sessionToDelete) return; - const handleExportSession = useCallback(async (session: SessionListItem, e: React.MouseEvent) => { - e.stopPropagation(); + setShowDeleteConfirmation(false); + const sessionToDeleteId = sessionToDelete.id; + const sessionName = sessionToDelete.name; + setSessionToDelete(null); - const json = await acpExportSession(session.id); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${session.name}.json`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - toast.success(intl.formatMessage(i18n.exportSuccess)); - }, [intl]); - - const handleShareSessionNostr = useCallback( - async (session: SessionListItem, e: React.MouseEvent) => { - e.stopPropagation(); - setSharingSessionId(session.id); - try { - const response = await acpShareSessionNostr(session.id, []); - setShareLink(response.deeplink); - setShowShareLinkModal(true); - toast.success(intl.formatMessage(i18n.shareNostrSuccess)); - } catch (error) { - toast.error(intl.formatMessage(i18n.shareNostrFailed, { error: errorMessage(error, 'Unknown error') })); - } finally { - setSharingSessionId(null); - } - }, - [intl] - ); + try { + await acpDeleteSession(sessionToDeleteId); + toast.success(intl.formatMessage(i18n.deleteSuccess)); + window.dispatchEvent( + new CustomEvent(AppEvents.SESSION_DELETED, { detail: { sessionId: sessionToDeleteId } }) + ); + cancelAcpPermissionRequestsForSession(sessionToDeleteId); + cancelAcpElicitationRequestsForSession(sessionToDeleteId); + acpChatSessionActions.deleteSnapshot(sessionToDeleteId); + } catch (error) { + console.error('Error deleting session:', error); + toast.error( + intl.formatMessage(i18n.deleteFailed, { + name: sessionName, + error: errorMessage(error, 'Unknown error'), + }) + ); + } + await loadSessions(); + }, [sessionToDelete, loadSessions, intl]); - const handleImportClick = useCallback(async () => { - const native = window.electron?.selectImportSessionFile; - if (typeof native === 'function') { - try { - const result = await native(); - if (!result) return; - if (result.error) { - toast.error(intl.formatMessage(i18n.importFailed, { error: result.error })); - return; - } - await acpImportSession(result.contents, 'json'); - toast.success(intl.formatMessage(i18n.importSuccess)); - window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - await loadSessions(); - } catch (error) { - toast.error( - intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') }) - ); - } - return; + const handleCancelDelete = useCallback(() => { + setShowDeleteConfirmation(false); + setSessionToDelete(null); + }, []); + + const handleExportSession = useCallback( + async (session: SessionListItem, format: SessionExportFormat) => { + try { + const data = await acpExportSession(session.id, format); + const isMarkdown = format === 'markdown'; + const blob = new Blob([data], { + type: isMarkdown ? 'text/markdown' : 'application/json', + }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${session.name}.${isMarkdown ? 'md' : 'json'}`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + toast.success(intl.formatMessage(i18n.exportSuccess)); + } catch (error) { + toast.error( + intl.formatMessage(i18n.exportFailed, { error: errorMessage(error, 'Unknown error') }) + ); } - // Fallback for non-Electron contexts (tests, web build). - fileInputRef.current?.click(); - }, [intl, loadSessions]); + }, + [intl] + ); - const handleImportNostrLink = useCallback(async () => { - const deeplink = nostrImportLink.trim(); - if (!deeplink) return; + const handleShareSessionNostr = useCallback( + async (session: SessionListItem, e: React.MouseEvent) => { + e.stopPropagation(); + setSharingSessionId(session.id); + try { + const response = await acpShareSessionNostr(session.id, []); + setShareLink(response.deeplink); + setShowShareLinkModal(true); + toast.success(intl.formatMessage(i18n.shareNostrSuccess)); + } catch (error) { + toast.error( + intl.formatMessage(i18n.shareNostrFailed, { error: errorMessage(error, 'Unknown error') }) + ); + } finally { + setSharingSessionId(null); + } + }, + [intl] + ); - setIsImportingNostr(true); + const handleImportClick = useCallback(async () => { + const native = window.electron?.selectImportSessionFile; + if (typeof native === 'function') { try { - await acpImportSession(deeplink, 'nostr'); - setNostrImportLink(''); - setShowImportLinkModal(false); + const result = await native(); + if (!result) return; + if (result.error) { + toast.error(intl.formatMessage(i18n.importFailed, { error: result.error })); + return; + } + await acpImportSession(result.contents, 'json'); toast.success(intl.formatMessage(i18n.importSuccess)); window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); await loadSessions(); } catch (error) { - toast.error(intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') })); - } finally { - setIsImportingNostr(false); + toast.error( + intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') }) + ); } - }, [intl, loadSessions, nostrImportLink]); + return; + } + // Fallback for non-Electron contexts (tests, web build). + fileInputRef.current?.click(); + }, [intl, loadSessions]); + + const handleImportNostrLink = useCallback(async () => { + const deeplink = nostrImportLink.trim(); + if (!deeplink) return; + + setIsImportingNostr(true); + try { + await acpImportSession(deeplink, 'nostr'); + setNostrImportLink(''); + setShowImportLinkModal(false); + toast.success(intl.formatMessage(i18n.importSuccess)); + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); + await loadSessions(); + } catch (error) { + toast.error( + intl.formatMessage(i18n.importFailed, { error: errorMessage(error, 'Unknown error') }) + ); + } finally { + setIsImportingNostr(false); + } + }, [intl, loadSessions, nostrImportLink]); + + const handleCopyShareLink = useCallback(async () => { + try { + await navigator.clipboard.writeText(shareLink); + toast.success(intl.formatMessage(i18n.copied)); + } catch (error) { + toast.error(`Failed to copy: ${errorMessage(error, 'Unknown error')}`); + } + }, [intl, shareLink]); + + const handleImportSession = useCallback( + async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; - const handleCopyShareLink = useCallback(async () => { try { - await navigator.clipboard.writeText(shareLink); - toast.success(intl.formatMessage(i18n.copied)); + const json = await file.text(); + await acpImportSession(json, 'json'); + + toast.success(intl.formatMessage(i18n.importSuccess)); + window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); + await loadSessions(); } catch (error) { - toast.error(`Failed to copy: ${errorMessage(error, 'Unknown error')}`); - } - }, [intl, shareLink]); - - const handleImportSession = useCallback( - async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - - try { - const json = await file.text(); - await acpImportSession(json, 'json'); - - toast.success(intl.formatMessage(i18n.importSuccess)); - window.dispatchEvent(new CustomEvent(AppEvents.SESSION_CREATED)); - await loadSessions(); - } catch (error) { - toast.error(intl.formatMessage(i18n.importFailed, { error: String(error) })); - } finally { - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } + toast.error(intl.formatMessage(i18n.importFailed, { error: String(error) })); + } finally { + if (fileInputRef.current) { + fileInputRef.current.value = ''; } + } + }, + [loadSessions, intl] + ); + + const handleOpenInNewWindow = useCallback((session: SessionListItem, e: React.MouseEvent) => { + e.stopPropagation(); + window.electron.createChatWindow({ + dir: session.workingDir, + resumeSessionId: session.id, + viewType: 'pair', + }); + }, []); + + const SessionItem = React.memo(function SessionItem({ + session, + onEditClick, + onDuplicateClick, + onDeleteClick, + onExportClick, + onShareClick, + onOpenInNewWindow, + isSharing, + }: { + session: SessionListItem; + onEditClick: (session: SessionListItem) => void; + onDuplicateClick: (session: SessionListItem) => void; + onDeleteClick: (session: SessionListItem) => void; + onExportClick: (session: SessionListItem, format: SessionExportFormat) => void; + onShareClick: (session: SessionListItem, e: React.MouseEvent) => void; + onOpenInNewWindow: (session: SessionListItem, e: React.MouseEvent) => void; + isSharing: boolean; + }) { + const handleEditClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onEditClick(session); }, - [loadSessions, intl] + [onEditClick, session] ); - const handleOpenInNewWindow = useCallback((session: SessionListItem, e: React.MouseEvent) => { - e.stopPropagation(); - window.electron.createChatWindow({ - dir: session.workingDir, - resumeSessionId: session.id, - viewType: 'pair', - }); - }, []); - - const SessionItem = React.memo(function SessionItem({ - session, - onEditClick, - onDuplicateClick, - onDeleteClick, - onExportClick, - onShareClick, - onOpenInNewWindow, - isSharing, - }: { - session: SessionListItem; - onEditClick: (session: SessionListItem) => void; - onDuplicateClick: (session: SessionListItem) => void; - onDeleteClick: (session: SessionListItem) => void; - onExportClick: (session: SessionListItem, e: React.MouseEvent) => void; - onShareClick: (session: SessionListItem, e: React.MouseEvent) => void; - onOpenInNewWindow: (session: SessionListItem, e: React.MouseEvent) => void; - isSharing: boolean; - }) { - const handleEditClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onEditClick(session); - }, - [onEditClick, session] - ); - - const handleDuplicateClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onDuplicateClick(session); - }, - [onDuplicateClick, session] - ); + const handleDuplicateClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDuplicateClick(session); + }, + [onDuplicateClick, session] + ); - const handleDeleteClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation(); - onDeleteClick(session); - }, - [onDeleteClick, session] - ); + const handleDeleteClick = useCallback( + (e: React.MouseEvent) => { + e.stopPropagation(); + onDeleteClick(session); + }, + [onDeleteClick, session] + ); - const handleCardClick = useCallback(() => { - onSelectSession(session.id); - }, [session.id]); + const handleCardClick = useCallback(() => { + onSelectSession(session.id); + }, [session.id]); - const handleExportClick = useCallback( - (e: React.MouseEvent) => { - onExportClick(session, e); - }, - [onExportClick, session] - ); + const handleExportSelect = useCallback( + (format: SessionExportFormat) => { + onExportClick(session, format); + }, + [onExportClick, session] + ); - const handleShareClick = useCallback( - (e: React.MouseEvent) => { - onShareClick(session, e); - }, - [onShareClick, session] - ); + const handleShareClick = useCallback( + (e: React.MouseEvent) => { + onShareClick(session, e); + }, + [onShareClick, session] + ); - const handleOpenInNewWindowClick = useCallback( - (e: React.MouseEvent) => { - onOpenInNewWindow(session, e); - }, - [onOpenInNewWindow, session] - ); + const handleOpenInNewWindowClick = useCallback( + (e: React.MouseEvent) => { + onOpenInNewWindow(session, e); + }, + [onOpenInNewWindow, session] + ); - const displayName = session.name; + const displayName = session.name; - return ( - -
-

{displayName}

-
-
- - - {formatMessageTimestamp(Date.parse(sessionActivityAt(session)) / 1000)} - -
-
- - {session.workingDir} -
+ return ( + +
+

{displayName}

+
+
+ + {formatMessageTimestamp(Date.parse(sessionActivityAt(session)) / 1000)} +
+
+ + {session.workingDir}
-
-
-
- - {session.messageCount} -
+
+
+
+
+ + {session.messageCount}
-
- - - - - - {nostrEnabled && ( +
+
+ + + + + + - )} -
- - ); - }); + + e.stopPropagation()}> + handleExportSelect('json')}> +
+ {intl.formatMessage(i18n.exportAsJson)} +
+
+ handleExportSelect('markdown')}> +
+ {intl.formatMessage(i18n.exportAsMarkdown)} +
+
+
+ + {nostrEnabled && ( + + )} +
+ + ); + }); - const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { - const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; - const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; - const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; + const SessionSkeleton = React.memo(({ variant = 0 }: { variant?: number }) => { + const titleWidths = ['w-3/4', 'w-2/3', 'w-4/5', 'w-1/2']; + const pathWidths = ['w-32', 'w-28', 'w-36', 'w-24']; + const tokenWidths = ['w-12', 'w-10', 'w-14', 'w-8']; - return ( - -
- -
+ return ( + +
+ +
+ + +
+
+ + +
+
+ +
+
+
- +
-
+
- -
-
- -
-
-
- - -
-
- - -
+
- - ); - }); +
+ + ); + }); - SessionSkeleton.displayName = 'SessionSkeleton'; + SessionSkeleton.displayName = 'SessionSkeleton'; - const renderActualContent = () => { - if (error) { - return ( -
- -

{intl.formatMessage(i18n.errorLoading)}

-

{error}

- -
- ); - } + const renderActualContent = () => { + if (error) { + return ( +
+ +

{intl.formatMessage(i18n.errorLoading)}

+

{error}

+ +
+ ); + } - if (sessions.length === 0) { - // `sessions` holds the keyword-filtered set, so an empty result while searching - // means "no matches" rather than "no sessions at all". - if (debouncedSearchTerm) { - return ( -
- -

{intl.formatMessage(i18n.noMatching)}

-

{intl.formatMessage(i18n.noMatchingDesc)}

-
- ); - } + if (sessions.length === 0) { + // `sessions` holds the keyword-filtered set, so an empty result while searching + // means "no matches" rather than "no sessions at all". + if (debouncedSearchTerm) { return ( -
+
-

{intl.formatMessage(i18n.noSessions)}

-

{intl.formatMessage(i18n.noSessionsDesc)}

+

{intl.formatMessage(i18n.noMatching)}

+

{intl.formatMessage(i18n.noMatchingDesc)}

); } - return ( -
- {visibleDateGroups.map((group) => ( -
-
-

{group.label}

-
-
- {group.sessions.map((session) => ( - - ))} -
-
- ))} - - {isPrefetchingSessions && ( -
-
-
- {intl.formatMessage(i18n.loadingMore)} -
-
- )} +
+ +

{intl.formatMessage(i18n.noSessions)}

+

{intl.formatMessage(i18n.noSessionsDesc)}

); - }; + } return ( - <> - -
-
-
-
-

{intl.formatMessage(i18n.chatHistory)}

-
- {nostrEnabled && ( - - )} +
+ {visibleDateGroups.map((group) => ( +
+
+

{group.label}

+
+
+ {group.sessions.map((session) => ( + + ))} +
+
+ ))} + + {isPrefetchingSessions && ( +
+
+
+ {intl.formatMessage(i18n.loadingMore)} +
+
+ )} +
+ ); + }; + + return ( + <> + +
+
+
+
+

{intl.formatMessage(i18n.chatHistory)}

+
+ {nostrEnabled && ( -
+ )} +
-

- {intl.formatMessage(i18n.chatHistoryDesc, { shortcut: getSearchShortcutText() })} -

+

+ {intl.formatMessage(i18n.chatHistoryDesc, { shortcut: getSearchShortcutText() })} +

+
-
- -
- + +
+ + {/* Skeleton layer - always rendered but conditionally visible */} +
- {/* Skeleton layer - always rendered but conditionally visible */} -
-
- {/* Today section */} -
- -
- - - - - -
+
+ {/* Today section */} +
+ +
+ + + + +
+
- {/* Yesterday section */} -
- -
- - - - - - -
+ {/* Yesterday section */} +
+ +
+ + + + + +
+
- {/* Additional section */} -
- -
- - - -
+ {/* Additional section */} +
+ +
+ + +
+
- {/* Content layer - always rendered but conditionally visible */} -
- {renderActualContent()} -
- -
- -
+ {/* Content layer - always rendered but conditionally visible */} +
+ {renderActualContent()} +
+ +
+
- - - - - - - - - - - - {intl.formatMessage(i18n.importNostrTitle)} - - {intl.formatMessage(i18n.importNostrDesc)} - - -