commit e9f56cf554c7434d4582d941033ce1ea58d5fe75
parent 8d2c748fb50201a1b9ede533e53cdf837cca8a54
Author: William Casarin <jb55@jb55.com>
Date: Wed, 4 Feb 2026 23:33:45 -0800
wip(dave): add session resume support (untested)
Add --resume equivalent for the cwd in the multi-headed headless Claude
GUI. When selecting a directory, the app now scans for existing Claude
sessions and offers to resume them.
New components:
- session_discovery.rs: Scans ~/.claude/projects/<path>/ for .jsonl
session files and extracts metadata (session ID, timestamp, summary)
- session_picker.rs: Full-screen overlay UI for selecting sessions to
resume, with keyboard shortcuts (1-9, N for new, B to go back)
Changes:
- ChatSession now has resume_session_id field
- SessionManager has new_resumed_session() method
- ClaudeBackend passes --resume flag via SDK when resuming
- Directory picker flow now shows session picker when resumable
sessions exist for the selected directory
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat:
8 files changed, 725 insertions(+), 11 deletions(-)
diff --git a/crates/notedeck_dave/src/backend/claude.rs b/crates/notedeck_dave/src/backend/claude.rs
@@ -130,6 +130,7 @@ struct PermissionRequestInternal {
async fn session_actor(
session_id: String,
cwd: Option<PathBuf>,
+ resume_session_id: Option<String>,
mut command_rx: tokio_mpsc::Receiver<SessionCommand>,
) {
// Permission channel - the callback sends to perm_tx, actor receives on perm_rx
@@ -183,16 +184,41 @@ async fn session_actor(
tracing::trace!("Claude CLI stderr: {}", msg);
});
+ // Log if we're resuming a session
+ if let Some(ref resume_id) = resume_session_id {
+ tracing::info!(
+ "Session {} will resume Claude session: {}",
+ session_id,
+ resume_id
+ );
+ }
+
// Create client once - this maintains the persistent connection
- let options = match cwd {
- Some(ref dir) => ClaudeAgentOptions::builder()
+ // Using match to handle the TypedBuilder's strict type requirements
+ let options = match (&cwd, &resume_session_id) {
+ (Some(dir), Some(resume_id)) => ClaudeAgentOptions::builder()
.permission_mode(PermissionMode::Default)
.stderr_callback(stderr_callback)
.can_use_tool(can_use_tool)
.include_partial_messages(true)
.cwd(dir)
+ .resume(resume_id)
+ .build(),
+ (Some(dir), None) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .cwd(dir)
+ .build(),
+ (None, Some(resume_id)) => ClaudeAgentOptions::builder()
+ .permission_mode(PermissionMode::Default)
+ .stderr_callback(stderr_callback)
+ .can_use_tool(can_use_tool)
+ .include_partial_messages(true)
+ .resume(resume_id)
.build(),
- None => ClaudeAgentOptions::builder()
+ (None, None) => ClaudeAgentOptions::builder()
.permission_mode(PermissionMode::Default)
.stderr_callback(stderr_callback)
.can_use_tool(can_use_tool)
@@ -551,6 +577,7 @@ impl AiBackend for ClaudeBackend {
_user_id: String,
session_id: String,
cwd: Option<PathBuf>,
+ resume_session_id: Option<String>,
ctx: egui::Context,
) -> (
mpsc::Receiver<DaveApiResponse>,
@@ -586,11 +613,18 @@ impl AiBackend for ClaudeBackend {
let handle = entry.or_insert_with(|| {
let (command_tx, command_rx) = tokio_mpsc::channel(16);
- // Spawn session actor with cwd
+ // Spawn session actor with cwd and optional resume session ID
let session_id_clone = session_id.clone();
let cwd_clone = cwd.clone();
+ let resume_session_id_clone = resume_session_id.clone();
tokio::spawn(async move {
- session_actor(session_id_clone, cwd_clone, command_rx).await;
+ session_actor(
+ session_id_clone,
+ cwd_clone,
+ resume_session_id_clone,
+ command_rx,
+ )
+ .await;
});
SessionHandle { command_tx }
diff --git a/crates/notedeck_dave/src/backend/openai.rs b/crates/notedeck_dave/src/backend/openai.rs
@@ -35,6 +35,7 @@ impl AiBackend for OpenAiBackend {
user_id: String,
_session_id: String,
_cwd: Option<PathBuf>,
+ _resume_session_id: Option<String>,
ctx: egui::Context,
) -> (
mpsc::Receiver<DaveApiResponse>,
diff --git a/crates/notedeck_dave/src/backend/traits.rs b/crates/notedeck_dave/src/backend/traits.rs
@@ -19,6 +19,9 @@ pub trait AiBackend: Send + Sync {
///
/// Returns a receiver that will receive tokens and tool calls as they arrive,
/// plus an optional JoinHandle to the spawned task for cleanup on session deletion.
+ ///
+ /// If `resume_session_id` is Some, the backend should resume the specified Claude
+ /// session instead of starting a new conversation.
#[allow(clippy::too_many_arguments)]
fn stream_request(
&self,
@@ -28,6 +31,7 @@ pub trait AiBackend: Send + Sync {
user_id: String,
session_id: String,
cwd: Option<PathBuf>,
+ resume_session_id: Option<String>,
ctx: egui::Context,
) -> (
mpsc::Receiver<DaveApiResponse>,
diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs
@@ -10,6 +10,7 @@ pub(crate) mod mesh;
mod messages;
mod quaternion;
pub mod session;
+pub mod session_discovery;
mod tools;
mod ui;
mod vec3;
@@ -36,6 +37,7 @@ pub use messages::{
};
pub use quaternion::Quaternion;
pub use session::{ChatSession, SessionId, SessionManager};
+pub use session_discovery::{discover_sessions, format_relative_time, ResumableSession};
pub use tools::{
PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse,
ToolResponses,
@@ -43,7 +45,7 @@ pub use tools::{
pub use ui::{
check_keybindings, AgentScene, DaveAction, DaveResponse, DaveSettingsPanel, DaveUi,
DirectoryPicker, DirectoryPickerAction, KeyAction, SceneAction, SceneResponse,
- SessionListAction, SessionListUi, SettingsPanelAction,
+ SessionListAction, SessionListUi, SessionPicker, SessionPickerAction, SettingsPanelAction,
};
pub use vec3::Vec3;
@@ -54,6 +56,7 @@ pub enum DaveOverlay {
None,
Settings,
DirectoryPicker,
+ SessionPicker,
}
pub struct Dave {
@@ -87,6 +90,8 @@ pub struct Dave {
home_session: Option<SessionId>,
/// Directory picker for selecting working directory when creating sessions
directory_picker: DirectoryPicker,
+ /// Session picker for resuming existing Claude sessions
+ session_picker: SessionPicker,
/// Current overlay taking over the UI (if any)
active_overlay: DaveOverlay,
/// IPC listener for external spawn-agent commands
@@ -184,6 +189,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
auto_steal_focus: false,
home_session: None,
directory_picker,
+ session_picker: SessionPicker::new(),
// Auto-show directory picker on startup since there are no sessions
active_overlay: DaveOverlay::DirectoryPicker,
ipc_listener,
@@ -390,6 +396,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
match self.active_overlay {
DaveOverlay::Settings => return self.settings_overlay_ui(app_ctx, ui),
DaveOverlay::DirectoryPicker => return self.directory_picker_overlay_ui(app_ctx, ui),
+ DaveOverlay::SessionPicker => return self.session_picker_overlay_ui(app_ctx, ui),
DaveOverlay::None => {}
}
@@ -434,8 +441,17 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
if let Some(action) = self.directory_picker.overlay_ui(ui, has_sessions) {
match action {
DirectoryPickerAction::DirectorySelected(path) => {
- self.create_session_with_cwd(path);
- self.active_overlay = DaveOverlay::None;
+ // Check if there are resumable sessions for this directory
+ let resumable_sessions = discover_sessions(&path);
+ if resumable_sessions.is_empty() {
+ // No previous sessions, create new directly
+ self.create_session_with_cwd(path);
+ self.active_overlay = DaveOverlay::None;
+ } else {
+ // Show session picker to let user choose
+ self.session_picker.open(path);
+ self.active_overlay = DaveOverlay::SessionPicker;
+ }
}
DirectoryPickerAction::Cancelled => {
// Only close if there are existing sessions to fall back to
@@ -451,6 +467,40 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
DaveResponse::default()
}
+ /// Full-screen session picker overlay (for resuming Claude sessions)
+ fn session_picker_overlay_ui(
+ &mut self,
+ _app_ctx: &mut AppContext,
+ ui: &mut egui::Ui,
+ ) -> DaveResponse {
+ if let Some(action) = self.session_picker.overlay_ui(ui) {
+ match action {
+ SessionPickerAction::ResumeSession {
+ cwd,
+ session_id,
+ title,
+ } => {
+ // Create a session that resumes the existing Claude conversation
+ self.create_resumed_session_with_cwd(cwd, session_id, title);
+ self.session_picker.close();
+ self.active_overlay = DaveOverlay::None;
+ }
+ SessionPickerAction::NewSession { cwd } => {
+ // User chose to start fresh
+ self.create_session_with_cwd(cwd);
+ self.session_picker.close();
+ self.active_overlay = DaveOverlay::None;
+ }
+ SessionPickerAction::BackToDirectoryPicker => {
+ // Go back to directory picker
+ self.session_picker.close();
+ self.active_overlay = DaveOverlay::DirectoryPicker;
+ }
+ }
+ }
+ DaveResponse::default()
+ }
+
/// Scene view with RTS-style agent visualization and chat side panel
fn scene_ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
use egui_extras::{Size, StripBuilder};
@@ -762,6 +812,30 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
}
}
+ /// Create a new session that resumes an existing Claude conversation
+ fn create_resumed_session_with_cwd(
+ &mut self,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+ ) {
+ // Add to recent directories
+ self.directory_picker.add_recent(cwd.clone());
+
+ let id = self
+ .session_manager
+ .new_resumed_session(cwd, resume_session_id, title);
+ // Request focus on the new session's input
+ if let Some(session) = self.session_manager.get_mut(id) {
+ session.focus_requested = true;
+ // Also update scene selection and camera if in scene view
+ if self.show_scene {
+ self.scene.select(id);
+ self.scene.focus_on(session.scene_position);
+ }
+ }
+ }
+
/// Clone the active agent, creating a new session with the same working directory
fn clone_active_agent(&mut self) {
if let Some(cwd) = self.session_manager.get_active().map(|s| s.cwd.clone()) {
@@ -1476,14 +1550,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr
let session_id = format!("dave-session-{}", session.id);
let messages = session.chat.clone();
let cwd = Some(session.cwd.clone());
+ let resume_session_id = session.resume_session_id.clone();
let tools = self.tools.clone();
let model_name = self.model_config.model().to_owned();
let ctx = ctx.clone();
// Use backend to stream request
- let (rx, task_handle) = self
- .backend
- .stream_request(messages, tools, model_name, user_id, session_id, cwd, ctx);
+ let (rx, task_handle) = self.backend.stream_request(
+ messages,
+ tools,
+ model_name,
+ user_id,
+ session_id,
+ cwd,
+ resume_session_id,
+ ctx,
+ );
session.incoming_tokens = Some(rx);
session.task_handle = task_handle;
}
diff --git a/crates/notedeck_dave/src/session.rs b/crates/notedeck_dave/src/session.rs
@@ -60,6 +60,9 @@ pub struct ChatSession {
pub is_compacting: bool,
/// Info from the last completed compaction (for display)
pub last_compaction: Option<CompactionInfo>,
+ /// Claude session ID to resume (UUID from Claude CLI's session storage)
+ /// When set, the backend will use --resume to continue this session
+ pub resume_session_id: Option<String>,
}
impl Drop for ChatSession {
@@ -98,9 +101,23 @@ impl ChatSession {
subagent_indices: HashMap::new(),
is_compacting: false,
last_compaction: None,
+ resume_session_id: None,
}
}
+ /// Create a new session that resumes an existing Claude conversation
+ pub fn new_resumed(
+ id: SessionId,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+ ) -> Self {
+ let mut session = Self::new(id, cwd);
+ session.resume_session_id = Some(resume_session_id);
+ session.title = title;
+ session
+ }
+
/// Update a subagent's output (appending new content, keeping only the tail)
pub fn update_subagent_output(&mut self, task_id: &str, new_output: &str) {
if let Some(&idx) = self.subagent_indices.get(task_id) {
@@ -224,6 +241,24 @@ impl SessionManager {
id
}
+ /// Create a new session that resumes an existing Claude conversation
+ pub fn new_resumed_session(
+ &mut self,
+ cwd: PathBuf,
+ resume_session_id: String,
+ title: String,
+ ) -> SessionId {
+ let id = self.next_id;
+ self.next_id += 1;
+
+ let session = ChatSession::new_resumed(id, cwd, resume_session_id, title);
+ self.sessions.insert(id, session);
+ self.order.insert(0, id); // Most recent first
+ self.active = Some(id);
+
+ id
+ }
+
/// Get a reference to the active session
pub fn get_active(&self) -> Option<&ChatSession> {
self.active.and_then(|id| self.sessions.get(&id))
diff --git a/crates/notedeck_dave/src/session_discovery.rs b/crates/notedeck_dave/src/session_discovery.rs
@@ -0,0 +1,243 @@
+//! Discovers resumable Claude Code sessions from the filesystem.
+//!
+//! Claude Code stores session data in ~/.claude/projects/<project-path>/
+//! where <project-path> is the cwd with slashes replaced by dashes and leading slash removed.
+
+use serde::Deserialize;
+use std::fs::{self, File};
+use std::io::{BufRead, BufReader};
+use std::path::{Path, PathBuf};
+
+/// Information about a resumable Claude session
+#[derive(Debug, Clone)]
+pub struct ResumableSession {
+ /// The UUID session identifier used by Claude CLI
+ pub session_id: String,
+ /// Path to the session JSONL file
+ pub file_path: PathBuf,
+ /// Timestamp of the most recent message
+ pub last_timestamp: chrono::DateTime<chrono::Utc>,
+ /// Summary/title derived from first user message
+ pub summary: String,
+ /// Number of messages in the session
+ pub message_count: usize,
+}
+
+/// A message entry from the JSONL file
+#[derive(Deserialize)]
+struct SessionEntry {
+ #[serde(rename = "sessionId")]
+ session_id: Option<String>,
+ timestamp: Option<String>,
+ #[serde(rename = "type")]
+ entry_type: Option<String>,
+ message: Option<MessageContent>,
+}
+
+#[derive(Deserialize)]
+struct MessageContent {
+ role: Option<String>,
+ content: Option<serde_json::Value>,
+}
+
+/// Converts a working directory to its Claude project path
+/// e.g., /home/jb55/dev/notedeck-dave -> -home-jb55-dev-notedeck-dave
+fn cwd_to_project_path(cwd: &Path) -> String {
+ let path_str = cwd.to_string_lossy();
+ // Replace path separators with dashes, keep the leading dash
+ path_str.replace('/', "-")
+}
+
+/// Get the Claude projects directory
+fn get_claude_projects_dir() -> Option<PathBuf> {
+ dirs::home_dir().map(|home| home.join(".claude").join("projects"))
+}
+
+/// Extract the first user message content as a summary
+fn extract_first_user_message(content: &serde_json::Value) -> Option<String> {
+ match content {
+ serde_json::Value::String(s) => {
+ // Clean up the message - remove "Human: " prefix if present
+ let cleaned = s.trim().strip_prefix("Human:").unwrap_or(s).trim();
+ // Take first 60 chars
+ let summary: String = cleaned.chars().take(60).collect();
+ if cleaned.len() > 60 {
+ Some(format!("{}...", summary))
+ } else {
+ Some(summary.to_string())
+ }
+ }
+ serde_json::Value::Array(arr) => {
+ // Content might be an array of content blocks
+ for item in arr {
+ if let Some(text) = item.get("text").and_then(|t| t.as_str()) {
+ let summary: String = text.chars().take(60).collect();
+ if text.len() > 60 {
+ return Some(format!("{}...", summary));
+ } else {
+ return Some(summary.to_string());
+ }
+ }
+ }
+ None
+ }
+ _ => None,
+ }
+}
+
+/// Parse a session JSONL file to extract session info
+fn parse_session_file(path: &Path) -> Option<ResumableSession> {
+ let file = File::open(path).ok()?;
+ let reader = BufReader::new(file);
+
+ let mut session_id: Option<String> = None;
+ let mut last_timestamp: Option<chrono::DateTime<chrono::Utc>> = None;
+ let mut first_user_message: Option<String> = None;
+ let mut message_count = 0;
+
+ for line in reader.lines() {
+ let line = line.ok()?;
+ if line.trim().is_empty() {
+ continue;
+ }
+
+ if let Ok(entry) = serde_json::from_str::<SessionEntry>(&line) {
+ // Get session ID from first entry that has it
+ if session_id.is_none() {
+ session_id = entry.session_id.clone();
+ }
+
+ // Track timestamp
+ if let Some(ts_str) = &entry.timestamp {
+ if let Ok(ts) = ts_str.parse::<chrono::DateTime<chrono::Utc>>() {
+ if last_timestamp.is_none() || ts > last_timestamp.unwrap() {
+ last_timestamp = Some(ts);
+ }
+ }
+ }
+
+ // Count user/assistant messages
+ if matches!(
+ entry.entry_type.as_deref(),
+ Some("user") | Some("assistant")
+ ) {
+ message_count += 1;
+
+ // Get first user message for summary
+ if entry.entry_type.as_deref() == Some("user") && first_user_message.is_none() {
+ if let Some(msg) = &entry.message {
+ if msg.role.as_deref() == Some("user") {
+ if let Some(content) = &msg.content {
+ first_user_message = extract_first_user_message(content);
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Need at least a session_id and some messages
+ let session_id = session_id?;
+ if message_count == 0 {
+ return None;
+ }
+
+ Some(ResumableSession {
+ session_id,
+ file_path: path.to_path_buf(),
+ last_timestamp: last_timestamp.unwrap_or_else(chrono::Utc::now),
+ summary: first_user_message.unwrap_or_else(|| "(no summary)".to_string()),
+ message_count,
+ })
+}
+
+/// Discover all resumable sessions for a given working directory
+pub fn discover_sessions(cwd: &Path) -> Vec<ResumableSession> {
+ let projects_dir = match get_claude_projects_dir() {
+ Some(dir) => dir,
+ None => return Vec::new(),
+ };
+
+ let project_path = cwd_to_project_path(cwd);
+ let session_dir = projects_dir.join(&project_path);
+
+ if !session_dir.exists() || !session_dir.is_dir() {
+ return Vec::new();
+ }
+
+ let mut sessions = Vec::new();
+
+ // Read all .jsonl files in the session directory
+ if let Ok(entries) = fs::read_dir(&session_dir) {
+ for entry in entries.flatten() {
+ let path = entry.path();
+ if path.extension().is_some_and(|ext| ext == "jsonl") {
+ if let Some(session) = parse_session_file(&path) {
+ sessions.push(session);
+ }
+ }
+ }
+ }
+
+ // Sort by most recent first
+ sessions.sort_by(|a, b| b.last_timestamp.cmp(&a.last_timestamp));
+
+ sessions
+}
+
+/// Format a timestamp for display (relative time like "2 hours ago")
+pub fn format_relative_time(timestamp: &chrono::DateTime<chrono::Utc>) -> String {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(*timestamp);
+
+ if duration.num_seconds() < 60 {
+ "just now".to_string()
+ } else if duration.num_minutes() < 60 {
+ let mins = duration.num_minutes();
+ format!("{} min{} ago", mins, if mins == 1 { "" } else { "s" })
+ } else if duration.num_hours() < 24 {
+ let hours = duration.num_hours();
+ format!("{} hour{} ago", hours, if hours == 1 { "" } else { "s" })
+ } else if duration.num_days() < 7 {
+ let days = duration.num_days();
+ format!("{} day{} ago", days, if days == 1 { "" } else { "s" })
+ } else {
+ timestamp.format("%Y-%m-%d").to_string()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_cwd_to_project_path() {
+ assert_eq!(
+ cwd_to_project_path(Path::new("/home/jb55/dev/notedeck-dave")),
+ "-home-jb55-dev-notedeck-dave"
+ );
+ assert_eq!(cwd_to_project_path(Path::new("/tmp/test")), "-tmp-test");
+ }
+
+ #[test]
+ fn test_extract_first_user_message_string() {
+ let content = serde_json::json!("Human: Hello, world!\n\n");
+ let result = extract_first_user_message(&content);
+ assert_eq!(result, Some("Hello, world!".to_string()));
+ }
+
+ #[test]
+ fn test_extract_first_user_message_array() {
+ let content = serde_json::json!([{"type": "text", "text": "Test message"}]);
+ let result = extract_first_user_message(&content);
+ assert_eq!(result, Some("Test message".to_string()));
+ }
+
+ #[test]
+ fn test_extract_first_user_message_truncation() {
+ let long_content = serde_json::json!("Human: This is a very long message that should be truncated because it exceeds sixty characters in length");
+ let result = extract_first_user_message(&long_content);
+ assert!(result.unwrap().ends_with("..."));
+ }
+}
diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs
@@ -9,6 +9,7 @@ mod pill;
mod query_ui;
pub mod scene;
pub mod session_list;
+pub mod session_picker;
mod settings;
mod top_buttons;
@@ -19,4 +20,5 @@ pub use keybind_hint::{keybind_hint, paint_keybind_hint};
pub use keybindings::{check_keybindings, KeyAction};
pub use scene::{AgentScene, SceneAction, SceneResponse};
pub use session_list::{SessionListAction, SessionListUi};
+pub use session_picker::{SessionPicker, SessionPickerAction};
pub use settings::{DaveSettingsPanel, SettingsPanelAction};
diff --git a/crates/notedeck_dave/src/ui/session_picker.rs b/crates/notedeck_dave/src/ui/session_picker.rs
@@ -0,0 +1,313 @@
+//! UI component for selecting resumable Claude sessions.
+
+use crate::session_discovery::{discover_sessions, format_relative_time, ResumableSession};
+use crate::ui::keybind_hint::paint_keybind_hint;
+use egui::{RichText, Vec2};
+use std::path::{Path, PathBuf};
+
+/// Maximum number of sessions to display
+const MAX_SESSIONS_DISPLAYED: usize = 10;
+
+/// Actions that can be triggered from the session picker
+#[derive(Debug, Clone)]
+pub enum SessionPickerAction {
+ /// User selected a session to resume
+ ResumeSession {
+ cwd: PathBuf,
+ session_id: String,
+ title: String,
+ },
+ /// User wants to start a new session (no resume)
+ NewSession { cwd: PathBuf },
+ /// User cancelled and wants to go back to directory picker
+ BackToDirectoryPicker,
+}
+
+/// State for the session picker modal
+pub struct SessionPicker {
+ /// The working directory we're showing sessions for
+ cwd: Option<PathBuf>,
+ /// Cached list of resumable sessions
+ sessions: Vec<ResumableSession>,
+ /// Whether the picker is currently open
+ pub is_open: bool,
+}
+
+impl Default for SessionPicker {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl SessionPicker {
+ pub fn new() -> Self {
+ Self {
+ cwd: None,
+ sessions: Vec::new(),
+ is_open: false,
+ }
+ }
+
+ /// Open the picker for a specific working directory
+ pub fn open(&mut self, cwd: PathBuf) {
+ self.sessions = discover_sessions(&cwd);
+ self.cwd = Some(cwd);
+ self.is_open = true;
+ }
+
+ /// Close the picker
+ pub fn close(&mut self) {
+ self.is_open = false;
+ self.cwd = None;
+ self.sessions.clear();
+ }
+
+ /// Check if there are sessions available to resume
+ pub fn has_sessions(&self) -> bool {
+ !self.sessions.is_empty()
+ }
+
+ /// Get the current working directory
+ pub fn cwd(&self) -> Option<&Path> {
+ self.cwd.as_deref()
+ }
+
+ /// Render the session picker as a full-panel overlay
+ pub fn overlay_ui(&mut self, ui: &mut egui::Ui) -> Option<SessionPickerAction> {
+ let cwd = self.cwd.clone()?;
+
+ let mut action = None;
+ let is_narrow = notedeck::ui::is_narrow(ui.ctx());
+ let ctrl_held = ui.input(|i| i.modifiers.ctrl);
+
+ // Handle keyboard shortcuts for sessions (1-9)
+ for (idx, session) in self.sessions.iter().take(9).enumerate() {
+ let key = match idx {
+ 0 => egui::Key::Num1,
+ 1 => egui::Key::Num2,
+ 2 => egui::Key::Num3,
+ 3 => egui::Key::Num4,
+ 4 => egui::Key::Num5,
+ 5 => egui::Key::Num6,
+ 6 => egui::Key::Num7,
+ 7 => egui::Key::Num8,
+ 8 => egui::Key::Num9,
+ _ => continue,
+ };
+ if ui.input(|i| i.key_pressed(key)) {
+ return Some(SessionPickerAction::ResumeSession {
+ cwd,
+ session_id: session.session_id.clone(),
+ title: session.summary.clone(),
+ });
+ }
+ }
+
+ // Handle N key for new session
+ if ui.input(|i| i.key_pressed(egui::Key::N)) {
+ return Some(SessionPickerAction::NewSession { cwd });
+ }
+
+ // Handle Escape/B key to go back
+ if ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::B)) {
+ return Some(SessionPickerAction::BackToDirectoryPicker);
+ }
+
+ // Full panel frame
+ egui::Frame::new()
+ .fill(ui.visuals().panel_fill)
+ .inner_margin(egui::Margin::symmetric(if is_narrow { 16 } else { 40 }, 20))
+ .show(ui, |ui| {
+ // Header
+ ui.horizontal(|ui| {
+ if ui.button("< Back").clicked() {
+ action = Some(SessionPickerAction::BackToDirectoryPicker);
+ }
+ ui.add_space(16.0);
+ ui.heading("Resume Session");
+ });
+
+ ui.add_space(8.0);
+
+ // Show the cwd
+ ui.label(RichText::new(abbreviate_path(&cwd)).monospace().weak());
+
+ ui.add_space(16.0);
+
+ // Centered content
+ let max_content_width = if is_narrow {
+ ui.available_width()
+ } else {
+ 600.0
+ };
+ let available_height = ui.available_height();
+
+ ui.allocate_ui_with_layout(
+ egui::vec2(max_content_width, available_height),
+ egui::Layout::top_down(egui::Align::LEFT),
+ |ui| {
+ // New session button at top
+ ui.horizontal(|ui| {
+ let new_button = egui::Button::new(
+ RichText::new("+ New Session").size(if is_narrow {
+ 16.0
+ } else {
+ 14.0
+ }),
+ )
+ .min_size(Vec2::new(
+ if is_narrow {
+ ui.available_width() - 28.0
+ } else {
+ 150.0
+ },
+ if is_narrow { 48.0 } else { 36.0 },
+ ));
+
+ let response = ui.add(new_button);
+
+ // Show keybind hint when Ctrl is held
+ if ctrl_held {
+ let hint_center =
+ response.rect.right_center() + egui::vec2(14.0, 0.0);
+ paint_keybind_hint(ui, hint_center, "N", 18.0);
+ }
+
+ if response
+ .on_hover_text("Start a new conversation (N)")
+ .clicked()
+ {
+ action = Some(SessionPickerAction::NewSession { cwd: cwd.clone() });
+ }
+ });
+
+ ui.add_space(16.0);
+ ui.separator();
+ ui.add_space(12.0);
+
+ // Sessions list
+ if self.sessions.is_empty() {
+ ui.label(
+ RichText::new("No previous sessions found for this directory.")
+ .weak(),
+ );
+ } else {
+ ui.label(RichText::new("Recent Sessions").strong());
+ ui.add_space(8.0);
+
+ let scroll_height = if is_narrow {
+ (ui.available_height() - 80.0).max(100.0)
+ } else {
+ 400.0
+ };
+
+ egui::ScrollArea::vertical()
+ .max_height(scroll_height)
+ .show(ui, |ui| {
+ for (idx, session) in self
+ .sessions
+ .iter()
+ .take(MAX_SESSIONS_DISPLAYED)
+ .enumerate()
+ {
+ let button_height = if is_narrow { 64.0 } else { 50.0 };
+ let hint_width =
+ if ctrl_held && idx < 9 { 24.0 } else { 0.0 };
+ let button_width = ui.available_width() - hint_width - 4.0;
+
+ ui.horizontal(|ui| {
+ // Create a frame for the session button
+ let response = ui.add(
+ egui::Button::new("")
+ .min_size(Vec2::new(
+ button_width,
+ button_height,
+ ))
+ .fill(
+ ui.visuals().widgets.inactive.weak_bg_fill,
+ ),
+ );
+
+ // Draw the content over the button
+ let rect = response.rect;
+ let painter = ui.painter();
+
+ // Summary text (truncated)
+ let summary_text = &session.summary;
+ let text_color = ui.visuals().text_color();
+ painter.text(
+ rect.left_top() + egui::vec2(8.0, 8.0),
+ egui::Align2::LEFT_TOP,
+ summary_text,
+ egui::FontId::proportional(13.0),
+ text_color,
+ );
+
+ // Metadata line (time + message count)
+ let meta_text = format!(
+ "{} • {} messages",
+ format_relative_time(&session.last_timestamp),
+ session.message_count
+ );
+ painter.text(
+ rect.left_bottom() + egui::vec2(8.0, -8.0),
+ egui::Align2::LEFT_BOTTOM,
+ meta_text,
+ egui::FontId::proportional(11.0),
+ ui.visuals().weak_text_color(),
+ );
+
+ // Show keybind hint when Ctrl is held
+ if ctrl_held && idx < 9 {
+ let hint_text = format!("{}", idx + 1);
+ let hint_center = response.rect.right_center()
+ + egui::vec2(hint_width / 2.0 + 2.0, 0.0);
+ paint_keybind_hint(
+ ui,
+ hint_center,
+ &hint_text,
+ 18.0,
+ );
+ }
+
+ if response.clicked() {
+ action = Some(SessionPickerAction::ResumeSession {
+ cwd: cwd.clone(),
+ session_id: session.session_id.clone(),
+ title: session.summary.clone(),
+ });
+ }
+ });
+
+ ui.add_space(4.0);
+ }
+
+ if self.sessions.len() > MAX_SESSIONS_DISPLAYED {
+ ui.add_space(8.0);
+ ui.label(
+ RichText::new(format!(
+ "... and {} more sessions",
+ self.sessions.len() - MAX_SESSIONS_DISPLAYED
+ ))
+ .weak(),
+ );
+ }
+ });
+ }
+ },
+ );
+ });
+
+ action
+ }
+}
+
+/// Abbreviate a path for display (e.g., replace home dir with ~)
+fn abbreviate_path(path: &Path) -> String {
+ if let Some(home) = dirs::home_dir() {
+ if let Ok(relative) = path.strip_prefix(&home) {
+ return format!("~/{}", relative.display());
+ }
+ }
+ path.display().to_string()
+}