feat: better session creation handling
This commit is contained in:
14
src/app.rs
14
src/app.rs
@@ -27,7 +27,7 @@ use crate::settings::{
|
|||||||
use iced::widget::{markdown, scrollable};
|
use iced::widget::{markdown, scrollable};
|
||||||
use iced::{Size, Subscription, Task, keyboard, window};
|
use iced::{Size, Subscription, Task, keyboard, window};
|
||||||
use rfd::AsyncFileDialog;
|
use rfd::AsyncFileDialog;
|
||||||
use std::collections::VecDeque;
|
use std::collections::{HashMap, VecDeque};
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||||
@@ -55,6 +55,9 @@ pub(crate) struct App {
|
|||||||
preference_error: Option<String>,
|
preference_error: Option<String>,
|
||||||
selected_project: Option<i32>,
|
selected_project: Option<i32>,
|
||||||
selected_session: Option<i32>,
|
selected_session: Option<i32>,
|
||||||
|
/// Unsaved sessions, keyed by project. A draft only becomes a `sessions` row
|
||||||
|
/// when its first chat turn is stored, so empty ones vanish on restart.
|
||||||
|
drafts: HashMap<i32, String>,
|
||||||
choosing_folder: bool,
|
choosing_folder: bool,
|
||||||
pending_project_path: Option<PathBuf>,
|
pending_project_path: Option<PathBuf>,
|
||||||
project_name_input: String,
|
project_name_input: String,
|
||||||
@@ -163,7 +166,8 @@ pub(crate) enum Message {
|
|||||||
CancelProject,
|
CancelProject,
|
||||||
SelectProject(i32),
|
SelectProject(i32),
|
||||||
DeleteProject(i32),
|
DeleteProject(i32),
|
||||||
CreateSession,
|
CreateSession(i32),
|
||||||
|
DiscardSession(i32),
|
||||||
SelectSession(i32, i32),
|
SelectSession(i32, i32),
|
||||||
DeleteSession(i32),
|
DeleteSession(i32),
|
||||||
ShowChat,
|
ShowChat,
|
||||||
@@ -204,6 +208,7 @@ impl App {
|
|||||||
preference_error: None,
|
preference_error: None,
|
||||||
selected_project: None,
|
selected_project: None,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
|
drafts: HashMap::new(),
|
||||||
choosing_folder: false,
|
choosing_folder: false,
|
||||||
pending_project_path: None,
|
pending_project_path: None,
|
||||||
project_name_input: String::new(),
|
project_name_input: String::new(),
|
||||||
@@ -275,6 +280,7 @@ impl App {
|
|||||||
preference_error: None,
|
preference_error: None,
|
||||||
selected_project: None,
|
selected_project: None,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
|
drafts: HashMap::new(),
|
||||||
choosing_folder: false,
|
choosing_folder: false,
|
||||||
pending_project_path: None,
|
pending_project_path: None,
|
||||||
project_name_input: String::new(),
|
project_name_input: String::new(),
|
||||||
@@ -675,6 +681,7 @@ impl App {
|
|||||||
for session_id in checkpoint_ids {
|
for session_id in checkpoint_ids {
|
||||||
let _ = fs::remove_file(session_checkpoint_path(session_id));
|
let _ = fs::remove_file(session_checkpoint_path(session_id));
|
||||||
}
|
}
|
||||||
|
self.drafts.remove(&project_id);
|
||||||
if self.selected_project == Some(project_id) {
|
if self.selected_project == Some(project_id) {
|
||||||
self.selected_project = None;
|
self.selected_project = None;
|
||||||
self.selected_session = None;
|
self.selected_session = None;
|
||||||
@@ -689,7 +696,8 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::CreateSession => self.create_session(),
|
Message::CreateSession(project_id) => self.create_session(project_id),
|
||||||
|
Message::DiscardSession(project_id) => self.discard_session(project_id),
|
||||||
Message::SelectSession(project_id, session_id) => {
|
Message::SelectSession(project_id, session_id) => {
|
||||||
if self.generating && self.selected_session != Some(session_id) {
|
if self.generating && self.selected_session != Some(session_id) {
|
||||||
self.error =
|
self.error =
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ impl From<StoredMessage> for ChatMessage {
|
|||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
pub(super) fn start_generation(&mut self) {
|
pub(super) fn start_generation(&mut self) {
|
||||||
if self.generating || self.selected_session.is_none() {
|
if self.generating || self.selected_project.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let prompt = self.composer.trim().to_owned();
|
let prompt = self.composer.trim().to_owned();
|
||||||
@@ -78,9 +78,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
let assistant_reasoning = effective.turn.reasoning_mode != ReasoningMode::Direct;
|
||||||
let session_id = self
|
|
||||||
.selected_session
|
|
||||||
.expect("a selected session was checked");
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
let mut messages = self
|
let mut messages = self
|
||||||
.conversation
|
.conversation
|
||||||
@@ -104,6 +101,22 @@ impl App {
|
|||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
{
|
{
|
||||||
|
// A draft session only reaches the database once there is a turn to store.
|
||||||
|
let session_id = match self.selected_session {
|
||||||
|
Some(session_id) => session_id,
|
||||||
|
None => {
|
||||||
|
let Some(project_id) = self.selected_project else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match self.persist_session(project_id) {
|
||||||
|
Ok(session_id) => session_id,
|
||||||
|
Err(error) => {
|
||||||
|
self.error = Some(format!("Could not create the session: {error}"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
let Some(service) = &self.generation_service else {
|
let Some(service) = &self.generation_service else {
|
||||||
self.error = Some("The model runtime is unavailable.".into());
|
self.error = Some("The model runtime is unavailable.".into());
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -58,37 +58,61 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn create_session(&mut self) {
|
/// Opens an unsaved session on a project and selects it. Nothing reaches the
|
||||||
|
/// database until the first chat turn is stored by [`App::persist_session`].
|
||||||
|
pub(super) fn create_session(&mut self, project_id: i32) {
|
||||||
if self.generating {
|
if self.generating {
|
||||||
self.error = Some("Stop the active generation before creating a session.".into());
|
self.error = Some("Stop the active generation before creating a session.".into());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let Some(project_id) = self.selected_project else {
|
if self.database.is_none() {
|
||||||
self.error = Some("Select a project first.".into());
|
|
||||||
return;
|
return;
|
||||||
};
|
}
|
||||||
let default_number = self
|
let title = draft_title(&self.projects, project_id);
|
||||||
.selected_project()
|
self.drafts.entry(project_id).or_insert(title);
|
||||||
.map(|project| project.sessions.len() + 1)
|
self.selected_project = Some(project_id);
|
||||||
.unwrap_or(1);
|
self.selected_session = None;
|
||||||
let title = format!("Session {default_number}");
|
|
||||||
let Some(database) = &mut self.database else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
match database.create_session(project_id, &title) {
|
|
||||||
Ok(session) => {
|
|
||||||
self.selected_session = Some(session.id);
|
|
||||||
self.conversation.clear();
|
self.conversation.clear();
|
||||||
self.composer.clear();
|
self.composer.clear();
|
||||||
self.context_used = 0;
|
self.context_used = 0;
|
||||||
self.context_limit = self.preferences.context_tokens.max(0) as u32;
|
self.context_limit = self.preferences.context_tokens.max(0) as u32;
|
||||||
self.tokens_per_second = None;
|
self.tokens_per_second = None;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn discard_session(&mut self, project_id: i32) {
|
||||||
|
if self.drafts.remove(&project_id).is_some() && self.draft_selected(project_id) {
|
||||||
|
self.conversation.clear();
|
||||||
|
self.composer.clear();
|
||||||
|
self.context_used = 0;
|
||||||
|
self.tokens_per_second = None;
|
||||||
|
}
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the draft on `project_id` into a real session row. Called when the
|
||||||
|
/// first chat turn is about to be written, never before.
|
||||||
|
pub(super) fn persist_session(&mut self, project_id: i32) -> Result<i32, String> {
|
||||||
|
let title = self
|
||||||
|
.drafts
|
||||||
|
.get(&project_id)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| draft_title(&self.projects, project_id));
|
||||||
|
let database = self
|
||||||
|
.database
|
||||||
|
.as_mut()
|
||||||
|
.ok_or_else(|| "The project database is unavailable.".to_owned())?;
|
||||||
|
let session = database.create_session(project_id, &title)?;
|
||||||
|
self.drafts.remove(&project_id);
|
||||||
|
self.selected_project = Some(project_id);
|
||||||
|
self.selected_session = Some(session.id);
|
||||||
self.reload_projects();
|
self.reload_projects();
|
||||||
|
Ok(session.id)
|
||||||
}
|
}
|
||||||
Err(error) => self.error = Some(error),
|
|
||||||
}
|
/// True when the sidebar row for this project's draft is the active chat.
|
||||||
|
pub(super) fn draft_selected(&self, project_id: i32) -> bool {
|
||||||
|
self.selected_project == Some(project_id) && self.selected_session.is_none()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn reload_projects(&mut self) {
|
pub(super) fn reload_projects(&mut self) {
|
||||||
@@ -100,3 +124,53 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn draft_title(projects: &[ProjectWithSessions], project_id: i32) -> String {
|
||||||
|
let stored = projects
|
||||||
|
.iter()
|
||||||
|
.find(|item| item.project.id == project_id)
|
||||||
|
.map_or(0, |item| item.sessions.len());
|
||||||
|
format!("Session {}", stored + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::database::{Project, Session};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draft_titles_follow_the_stored_session_count() {
|
||||||
|
let projects = vec![
|
||||||
|
ProjectWithSessions {
|
||||||
|
project: project(1, "First"),
|
||||||
|
sessions: vec![session(1, 1), session(2, 1)],
|
||||||
|
},
|
||||||
|
ProjectWithSessions {
|
||||||
|
project: project(2, "Second"),
|
||||||
|
sessions: Vec::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(draft_title(&projects, 1), "Session 3");
|
||||||
|
assert_eq!(draft_title(&projects, 2), "Session 1");
|
||||||
|
assert_eq!(draft_title(&projects, 99), "Session 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn project(id: i32, name: &str) -> Project {
|
||||||
|
Project {
|
||||||
|
id,
|
||||||
|
name: name.into(),
|
||||||
|
path: format!("/tmp/{name}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session(id: i32, project_id: i32) -> Session {
|
||||||
|
Session {
|
||||||
|
id,
|
||||||
|
project_id,
|
||||||
|
title: format!("Session {id}"),
|
||||||
|
context_used: 0,
|
||||||
|
context_limit: 0,
|
||||||
|
last_tokens_per_second: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,16 +81,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn sidebar(&self) -> Element<'_, Message> {
|
fn sidebar(&self) -> Element<'_, Message> {
|
||||||
let new_session_content = row![icon(ICON_NEW_SESSION, 17), text("New session").size(14),]
|
|
||||||
.spacing(9)
|
|
||||||
.align_y(Alignment::Center);
|
|
||||||
let new_session = if self.selected_project.is_some() {
|
|
||||||
button(new_session_content)
|
|
||||||
.width(Length::Fill)
|
|
||||||
.on_press(Message::CreateSession)
|
|
||||||
} else {
|
|
||||||
button(new_session_content).width(Length::Fill)
|
|
||||||
};
|
|
||||||
let preference_content = row![
|
let preference_content = row![
|
||||||
icon(ICON_SETTINGS, 17),
|
icon(ICON_SETTINGS, 17),
|
||||||
text("Preferences").size(14),
|
text("Preferences").size(14),
|
||||||
@@ -116,16 +106,12 @@ impl App {
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.on_press(Message::ChooseProjectFolder)
|
.on_press(Message::ChooseProjectFolder)
|
||||||
};
|
};
|
||||||
let header = column![
|
let header = row![
|
||||||
row![
|
|
||||||
text("DS4Server").size(20),
|
text("DS4Server").size(20),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
text("Local").size(12),
|
text("Local").size(12),
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center);
|
||||||
new_session.style(button::text),
|
|
||||||
]
|
|
||||||
.spacing(10);
|
|
||||||
let mut projects = column![
|
let mut projects = column![
|
||||||
row![
|
row![
|
||||||
text("PROJECTS").size(11),
|
text("PROJECTS").size(11),
|
||||||
@@ -149,6 +135,9 @@ impl App {
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.on_press(Message::SelectProject(project.id))
|
.on_press(Message::SelectProject(project.id))
|
||||||
.style(button::text),
|
.style(button::text),
|
||||||
|
button(icon(ICON_NEW_SESSION, 15))
|
||||||
|
.on_press(Message::CreateSession(project.id))
|
||||||
|
.style(button::text),
|
||||||
button(icon(ICON_TRASH, 15))
|
button(icon(ICON_TRASH, 15))
|
||||||
.on_press(Message::DeleteProject(project.id))
|
.on_press(Message::DeleteProject(project.id))
|
||||||
.style(button::text),
|
.style(button::text),
|
||||||
@@ -182,6 +171,35 @@ impl App {
|
|||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(title) = self.drafts.get(&project.id) {
|
||||||
|
let draft_selected = self.draft_selected(project.id);
|
||||||
|
projects = projects.push(
|
||||||
|
row![
|
||||||
|
Space::with_width(26),
|
||||||
|
button(
|
||||||
|
row![
|
||||||
|
icon(ICON_CHAT, 15),
|
||||||
|
text(title).size(13).color(muted_text())
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::CreateSession(project.id))
|
||||||
|
.style(if draft_selected {
|
||||||
|
button::secondary
|
||||||
|
} else {
|
||||||
|
button::text
|
||||||
|
}),
|
||||||
|
button(icon(ICON_TRASH, 14))
|
||||||
|
.on_press(Message::DiscardSession(project.id))
|
||||||
|
.style(button::text),
|
||||||
|
]
|
||||||
|
.spacing(3)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container(
|
container(
|
||||||
@@ -281,6 +299,18 @@ impl App {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|session| Some(session.id) == self.selected_session)
|
.find(|session| Some(session.id) == self.selected_session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Title of the active chat surface: a stored session, or this project's
|
||||||
|
/// unsaved draft. `None` means no chat is open for the project.
|
||||||
|
fn active_session_title<'a>(&'a self, project: &'a ProjectWithSessions) -> Option<&'a str> {
|
||||||
|
match self.selected_session(project) {
|
||||||
|
Some(session) => Some(session.title.as_str()),
|
||||||
|
None if self.draft_selected(project.project.id) => {
|
||||||
|
self.drafts.get(&project.project.id).map(String::as_str)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn preference_input_row<'a>(
|
fn preference_input_row<'a>(
|
||||||
|
|||||||
@@ -29,10 +29,8 @@ impl App {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let project = &item.project;
|
let project = &item.project;
|
||||||
let selected_title = self
|
let active_title = self.active_session_title(item);
|
||||||
.selected_session(item)
|
let selected_title = active_title.unwrap_or(project.name.as_str());
|
||||||
.map(|session| session.title.as_str())
|
|
||||||
.unwrap_or(project.name.as_str());
|
|
||||||
let header = row![
|
let header = row![
|
||||||
icon(ICON_FOLDER, 19),
|
icon(ICON_FOLDER, 19),
|
||||||
text(selected_title).size(18),
|
text(selected_title).size(18),
|
||||||
@@ -42,12 +40,12 @@ impl App {
|
|||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
|
|
||||||
let body: Element<'_, Message> = if let Some(session) = self.selected_session(item) {
|
let body: Element<'_, Message> = if let Some(title) = active_title {
|
||||||
let mut messages = column![].spacing(12);
|
let mut messages = column![].spacing(12);
|
||||||
if self.conversation.is_empty() {
|
if self.conversation.is_empty() {
|
||||||
messages = messages.push(
|
messages = messages.push(
|
||||||
column![
|
column![
|
||||||
text(&session.title).size(26),
|
text(title).size(26),
|
||||||
text("Run DeepSeek locally with the Rust Metal engine.").size(14),
|
text("Run DeepSeek locally with the Rust Metal engine.").size(14),
|
||||||
]
|
]
|
||||||
.spacing(8),
|
.spacing(8),
|
||||||
@@ -191,7 +189,8 @@ impl App {
|
|||||||
"Choose a session"
|
"Choose a session"
|
||||||
})
|
})
|
||||||
.size(24),
|
.size(24),
|
||||||
text("Create a session above or select one from the sidebar.").size(14),
|
text("Use the new session icon next to the project, or pick one from the sidebar.")
|
||||||
|
.size(14),
|
||||||
]
|
]
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.align_x(Alignment::Center),
|
.align_x(Alignment::Center),
|
||||||
|
|||||||
Reference in New Issue
Block a user