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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion src-tauri/src/commands/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use std::collections::{HashMap, HashSet};
use crate::app_error::AppCommandError;
use crate::db::entities::conversation;
use crate::db::entities::folder::FolderKind;
use crate::db::service::{conversation_service, folder_service, import_service, tab_service};
use crate::db::service::{
conversation_edit_service, conversation_service, folder_service, import_service, tab_service,
};
#[cfg(feature = "tauri-runtime")]
use crate::db::AppDatabase;
use crate::models::*;
Expand Down Expand Up @@ -1141,6 +1143,19 @@ pub async fn get_folder_conversation_core(
.unwrap_or_default();
inject_delegation_meta(&mut turns, &children);

// User-message edits hide the replaced tail. Applied here so every
// consumer (detail, older-page, live window) sees the same transcript.
match conversation_edit_service::get_hidden_timestamps(conn, conversation_id).await {
Ok(hidden) if !hidden.is_empty() => {
turns = conversation_edit_service::filter_hidden_turns(turns, &hidden);
summary.message_count = turns.len() as u32;
}
Ok(_) => {}
Err(e) => tracing::warn!(
"[conversations] failed to load edit-hidden timestamps for {conversation_id}: {e}"
),
}

Ok((
DbConversationDetail {
summary,
Expand Down Expand Up @@ -2000,6 +2015,42 @@ pub async fn update_conversation_pinned(
Ok(())
}

/// Persist timestamps of turns hidden by editing a previous user message.
/// See `conversation_edit_service`. An empty list is rejected so a missed
/// turn id cannot wipe (or no-op-hide) the transcript by accident.
pub async fn hide_conversation_turns_core(
conn: &sea_orm::DatabaseConnection,
conversation_id: i32,
hidden_timestamps_ms: Vec<i64>,
) -> Result<(), AppCommandError> {
if hidden_timestamps_ms.is_empty() {
return Err(AppCommandError::invalid_input(
"hidden_timestamps_ms must not be empty",
));
}
conversation_service::get_by_id(conn, conversation_id)
.await
.map_err(AppCommandError::from)?;
conversation_edit_service::add_hidden_timestamps(
conn,
conversation_id,
&hidden_timestamps_ms,
)
.await
.map_err(AppCommandError::from)?;
Ok(())
}

#[cfg(feature = "tauri-runtime")]
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn hide_conversation_turns(
db: tauri::State<'_, AppDatabase>,
conversation_id: i32,
hidden_timestamps_ms: Vec<i64>,
) -> Result<(), AppCommandError> {
hide_conversation_turns_core(&db.conn, conversation_id, hidden_timestamps_ms).await
}

pub async fn delete_conversation_core(
conn: &sea_orm::DatabaseConnection,
conversation_id: i32,
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/db/entities/conversation_edit_hidden.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
use sea_orm::entity::prelude::*;

/// Per-conversation timestamps of transcript turns hidden by an edit.
/// See `crate::db::service::conversation_edit_service`.
#[derive(Clone, Debug, PartialEq, DeriveEntityModel)]
#[sea_orm(table_name = "conversation_edit_hidden")]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub conversation_id: i32,
/// JSON array of millisecond timestamps, e.g. `[1710000000000, …]`.
pub hidden_ts_json: String,
pub updated_at: DateTimeUtc,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
1 change: 1 addition & 0 deletions src-tauri/src/db/entities/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod chat_channel_message_log;
pub mod chat_channel_sender_context;
pub mod chat_channel_thread_binding;
pub mod conversation;
pub mod conversation_edit_hidden;
pub mod custom_agent;
pub mod folder;
pub mod folder_command;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/db/entities/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog;
pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext;
pub use super::chat_channel_thread_binding::Entity as ChatChannelThreadBinding;
pub use super::conversation::Entity as Conversation;
pub use super::conversation_edit_hidden::Entity as ConversationEditHidden;
pub use super::custom_agent::Entity as CustomAgent;
pub use super::folder::Entity as Folder;
pub use super::folder_command::Entity as FolderCommand;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;

#[derive(DeriveMigrationName)]
pub struct Migration;

#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.create_table(
Table::create()
.table(ConversationEditHidden::Table)
.if_not_exists()
.col(
ColumnDef::new(ConversationEditHidden::ConversationId)
.integer()
.not_null()
.primary_key(),
)
.col(
ColumnDef::new(ConversationEditHidden::HiddenTsJson)
.text()
.not_null(),
)
.col(
ColumnDef::new(ConversationEditHidden::UpdatedAt)
.timestamp_with_time_zone()
.not_null(),
)
.to_owned(),
)
.await
}

async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
manager
.drop_table(
Table::drop()
.table(ConversationEditHidden::Table)
.to_owned(),
)
.await
}
}

#[derive(DeriveIden)]
enum ConversationEditHidden {
Table,
ConversationId,
HiddenTsJson,
UpdatedAt,
}
2 changes: 2 additions & 0 deletions src-tauri/src/db/migration/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ mod m20260803_000001_folder_link;
mod m20260803_000001_token_usage;
mod m20260807_000001_work_task_scheduled_at;
mod m20260808_000001_custom_agent_supports_mcp;
mod m20260815_000001_conversation_edit_hidden;
pub struct Migrator;

#[async_trait::async_trait]
Expand Down Expand Up @@ -80,6 +81,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260803_000001_token_usage::Migration),
Box::new(m20260807_000001_work_task_scheduled_at::Migration),
Box::new(m20260808_000001_custom_agent_supports_mcp::Migration),
Box::new(m20260815_000001_conversation_edit_hidden::Migration),
]
}
}
124 changes: 124 additions & 0 deletions src-tauri/src/db/service/conversation_edit_service.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
use std::collections::BTreeSet;

use chrono::Utc;
use sea_orm::sea_query::OnConflict;
use sea_orm::{DatabaseConnection, EntityTrait, Set};

use crate::db::entities::conversation_edit_hidden;
use crate::db::error::DbError;
use crate::models::message::MessageTurn;

/// Timestamps currently hidden for this conversation. Empty when the user
/// has never edited a message in it.
pub async fn get_hidden_timestamps(
conn: &DatabaseConnection,
conversation_id: i32,
) -> Result<BTreeSet<i64>, DbError> {
let Some(row) = conversation_edit_hidden::Entity::find_by_id(conversation_id)
.one(conn)
.await?
else {
return Ok(BTreeSet::new());
};
Ok(parse_hidden_ts_json(&row.hidden_ts_json))
}

/// Union `added` into the stored hide set. An empty `added` is a no-op so a
/// caller that failed to resolve the edited turn cannot wipe the transcript.
pub async fn add_hidden_timestamps(
conn: &DatabaseConnection,
conversation_id: i32,
added: &[i64],
) -> Result<BTreeSet<i64>, DbError> {
if added.is_empty() {
return get_hidden_timestamps(conn, conversation_id).await;
}
let mut hidden = get_hidden_timestamps(conn, conversation_id).await?;
hidden.extend(added.iter().copied());
let now = Utc::now();
let json = serde_json::to_string(&hidden.iter().copied().collect::<Vec<i64>>())
.unwrap_or_else(|_| "[]".to_string());
let model = conversation_edit_hidden::ActiveModel {
conversation_id: Set(conversation_id),
hidden_ts_json: Set(json),
updated_at: Set(now),
};
conversation_edit_hidden::Entity::insert(model)
.on_conflict(
OnConflict::column(conversation_edit_hidden::Column::ConversationId)
.update_columns([
conversation_edit_hidden::Column::HiddenTsJson,
conversation_edit_hidden::Column::UpdatedAt,
])
.to_owned(),
)
.exec(conn)
.await?;
Ok(hidden)
}

/// Drop turns whose timestamp is in `hidden`. Unparseable timestamps stay
/// visible — better a leftover than a silently swallowed turn.
pub fn filter_hidden_turns(turns: Vec<MessageTurn>, hidden: &BTreeSet<i64>) -> Vec<MessageTurn> {
if hidden.is_empty() {
return turns;
}
turns
.into_iter()
.filter(|turn| !hidden.contains(&turn.timestamp.timestamp_millis()))
.collect()
}

fn parse_hidden_ts_json(raw: &str) -> BTreeSet<i64> {
serde_json::from_str::<Vec<i64>>(raw)
.unwrap_or_default()
.into_iter()
.collect()
}

#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
use crate::models::message::{MessageTurn, TurnRole};

fn turn(id: &str, ms: i64) -> MessageTurn {
MessageTurn {
id: id.to_string(),
role: TurnRole::User,
blocks: vec![],
timestamp: Utc.timestamp_millis_opt(ms).single().expect("valid ms"),
usage: None,
duration_ms: None,
model: None,
completed_at: None,
}
}

#[test]
fn filter_drops_only_hidden_timestamps() {
let turns = vec![turn("a", 1000), turn("b", 2000), turn("c", 3000)];
let hidden = BTreeSet::from([2000]);
let kept: Vec<_> = filter_hidden_turns(turns, &hidden)
.into_iter()
.map(|t| t.id)
.collect();
assert_eq!(kept, ["a", "c"]);
}

#[test]
fn filter_is_noop_when_empty() {
let turns = vec![turn("a", 1000)];
let hidden = BTreeSet::new();
assert_eq!(filter_hidden_turns(turns.clone(), &hidden).len(), 1);
}

#[test]
fn parse_accepts_a_json_array_and_ignores_garbage() {
assert_eq!(
parse_hidden_ts_json("[1, 2, 2, 3]"),
BTreeSet::from([1, 2, 3])
);
assert!(parse_hidden_ts_json("nope").is_empty());
}
}
1 change: 1 addition & 0 deletions src-tauri/src/db/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ pub mod app_metadata_service;
pub mod automation_service;
pub mod chat_channel_message_log_service;
pub mod chat_channel_service;
pub mod conversation_edit_service;
pub mod conversation_service;
pub mod custom_agent_service;
pub mod folder_command_service;
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -988,6 +988,7 @@ mod tauri_app {
conversations::update_conversation_status,
conversations::update_conversation_title,
conversations::update_conversation_pinned,
conversations::hide_conversation_turns,
conversations::delete_conversation,
folders::load_folder_history,
folders::get_folder,
Expand Down
20 changes: 20 additions & 0 deletions src-tauri/src/web/handlers/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,26 @@ pub async fn update_conversation_pinned(
Ok(Json(()))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HideConversationTurnsParams {
pub conversation_id: i32,
pub hidden_timestamps_ms: Vec<i64>,
}

pub async fn hide_conversation_turns(
Extension(state): Extension<Arc<AppState>>,
Json(params): Json<HideConversationTurnsParams>,
) -> Result<Json<()>, AppCommandError> {
conv_commands::hide_conversation_turns_core(
&state.db.conn,
params.conversation_id,
params.hidden_timestamps_ms,
)
.await?;
Ok(Json(()))
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeleteConversationParams {
Expand Down
4 changes: 4 additions & 0 deletions src-tauri/src/web/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,10 @@ pub fn build_router(
"/update_conversation_pinned",
post(handlers::conversations::update_conversation_pinned),
)
.route(
"/hide_conversation_turns",
post(handlers::conversations::hide_conversation_turns),
)
.route(
"/delete_conversation",
post(handlers::conversations::delete_conversation),
Expand Down
Loading
Loading