Skip to content
Closed
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
4 changes: 2 additions & 2 deletions crates/ironclaw_gateway/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -4213,8 +4213,8 @@ function threadTitle(thread) {
if (thread.thread_type === 'heartbeat') return I18n.t('thread.heartbeatAlerts');
if (thread.thread_type === 'routine') return I18n.t('thread.routine');
if (ch !== 'gateway') return ch.charAt(0).toUpperCase() + ch.slice(1);
if (thread.turn_count === 0) return 'New chat';
return thread.id.substring(0, 8);
if (thread.turn_count === 0) return I18n.t('thread.newChat');
return I18n.t('thread.untitled');
}

function relativeTime(isoStr) {
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_gateway/static/i18n/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,8 @@ I18n.register('en', {
// Thread types
'thread.heartbeatAlerts': 'Heartbeat Alerts',
'thread.routine': 'Routine',
'thread.newChat': 'New chat',
'thread.untitled': 'Untitled chat',

// Extensions (dynamic)
'extensions.openingAuth': 'Opening authentication for {name}',
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_gateway/static/i18n/ko.js
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,8 @@ I18n.register('ko', {
// 스레드 유형
'thread.heartbeatAlerts': '하트비트 알림',
'thread.routine': '루틴',
'thread.newChat': '새 대화',
'thread.untitled': '제목 없는 대화',

// 확장 (동적)
'extensions.openingAuth': '{name}에 대한 인증을 여는 중',
Expand Down
2 changes: 2 additions & 0 deletions crates/ironclaw_gateway/static/i18n/zh-CN.js
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,8 @@ I18n.register('zh-CN', {
// 线程类型
'thread.heartbeatAlerts': '心跳提醒',
'thread.routine': '定时任务',
'thread.newChat': '新对话',
'thread.untitled': '未命名对话',

// 扩展(动态)
'extensions.openingAuth': '正在为 {name} 打开认证',
Expand Down
8 changes: 6 additions & 2 deletions src/agent/thread_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1041,7 +1041,7 @@ impl Agent {
return None;
}

match store
let result = match store
.add_conversation_message(thread_id, "user", user_input)
.await
{
Expand Down Expand Up @@ -1076,7 +1076,11 @@ impl Agent {
.await;
None
}
}
};

crate::db::set_title_if_missing(store.as_ref(), thread_id, user_input).await;

result
}

/// Persist the assistant response to the DB after the agentic loop completes.
Expand Down
1 change: 1 addition & 0 deletions src/bridge/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2969,6 +2969,7 @@ async fn handle_with_engine_inner(
};
if let Some(cid) = v1_conv_id {
let _ = db.add_conversation_message(cid, "user", content).await;
crate::db::set_title_if_missing(db.as_ref(), cid, content).await;
}
}

Expand Down
25 changes: 16 additions & 9 deletions src/channels/web/handlers/chat.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,15 +170,22 @@ pub async fn chat_threads_handler(
sorted_threads.sort_by_key(|t| std::cmp::Reverse(t.updated_at));
let threads: Vec<ThreadInfo> = sorted_threads
.into_iter()
.map(|t| ThreadInfo {
id: t.id,
state: format!("{:?}", t.state),
turn_count: t.turns.len(),
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
title: None,
thread_type: None,
channel: Some("gateway".to_string()),
.map(|t| {
let title = t
.turns
.first()
.map(|turn| turn.user_input.trim().chars().take(100).collect::<String>())
.filter(|s| !s.is_empty());
ThreadInfo {
id: t.id,
state: format!("{:?}", t.state),
turn_count: t.turns.len(),
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
title,
thread_type: None,
channel: Some("gateway".to_string()),
}
})
.collect();

Expand Down
26 changes: 17 additions & 9 deletions src/channels/web/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1256,15 +1256,23 @@ pub(crate) async fn chat_threads_handler(
sorted_threads.sort_by_key(|t| std::cmp::Reverse(t.updated_at));
let threads: Vec<ThreadInfo> = sorted_threads
.into_iter()
.map(|t| ThreadInfo {
id: t.id,
state: thread_state_label(t.state).to_string(),
turn_count: t.turns.len(),
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
title: None,
thread_type: None,
channel: Some("gateway".to_string()),
.map(|t| {
// Derive title from first turn's user input when no DB is available
let title = t
.turns
.first()
.map(|turn| turn.user_input.trim().chars().take(100).collect::<String>())
.filter(|s| !s.is_empty());
ThreadInfo {
id: t.id,
state: thread_state_label(t.state).to_string(),
turn_count: t.turns.len(),
created_at: t.created_at.to_rfc3339(),
updated_at: t.updated_at.to_rfc3339(),
title,
thread_type: None,
channel: Some("gateway".to_string()),
}
})
.collect();

Expand Down
121 changes: 109 additions & 12 deletions src/db/libsql/conversations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,12 +171,20 @@ impl ConversationStore for LibSqlBackend {
.and_then(|v| v.as_str())
.map(String::from);
let sql_title = get_opt_text(&row, 6);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Filtering sql_title for empty strings ensures that the fallback logic correctly proceeds to check metadata.title or routine_name if the message-derived title is empty.

Suggested change
let sql_title = get_opt_text(&row, 6);
let sql_title = get_opt_text(&row, 6).filter(|s| !s.is_empty());

let title = sql_title.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
let title = sql_title
.or_else(|| {
metadata
.get("title")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
})
.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
results.push(ConversationSummary {
id: row
.get::<String>(0)
Expand Down Expand Up @@ -250,12 +258,20 @@ impl ConversationStore for LibSqlBackend {
.and_then(|v| v.as_str())
.map(String::from);
let sql_title = get_opt_text(&row, 6);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Filtering sql_title for empty strings ensures that the fallback logic correctly proceeds to check metadata.title or routine_name if the message-derived title is empty.

Suggested change
let sql_title = get_opt_text(&row, 6);
let sql_title = get_opt_text(&row, 6).filter(|s| !s.is_empty());

let title = sql_title.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
let title = sql_title
.or_else(|| {
metadata
.get("title")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
})
.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
results.push(ConversationSummary {
id: row
.get::<String>(0)
Expand Down Expand Up @@ -1009,4 +1025,85 @@ mod tests {
"assistant thread lookup should backfill a legacy NULL source_channel"
);
}

/// Regression test for #2237: conversations with a metadata title should
/// use it as a fallback when the message-derived title is NULL.
#[tokio::test]
async fn test_metadata_title_used_as_fallback() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_metadata_title.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();

let conv_id = Uuid::new_v4();
let user_id = "user-title-test";

// Create a conversation with no messages
backend
.ensure_conversation(conv_id, "gateway", user_id, None, Some("gateway"))
.await
.unwrap();

// Set a metadata title (simulating what persist_user_message does)
let title_val = serde_json::json!("What is the weather today?");
backend
.update_conversation_metadata_field(conv_id, "title", &title_val)
.await
.unwrap();

// List conversations -- title should come from metadata even without messages
let convs = backend
.list_conversations_all_channels(user_id, 50)
.await
.unwrap();

let conv = convs.iter().find(|c| c.id == conv_id).unwrap();
assert_eq!(
conv.title.as_deref(),
Some("What is the weather today?"),
"Conversation title should fall back to metadata title when no user messages exist"
);
}

/// Regression test for #2237: message-derived title takes precedence over metadata.
#[tokio::test]
async fn test_message_title_takes_precedence_over_metadata() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test_message_title_precedence.db");
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
backend.run_migrations().await.unwrap();

let conv_id = Uuid::new_v4();
let user_id = "user-title-precedence";

backend
.ensure_conversation(conv_id, "gateway", user_id, None, Some("gateway"))
.await
.unwrap();

// Set metadata title
let title_val = serde_json::json!("metadata title");
backend
.update_conversation_metadata_field(conv_id, "title", &title_val)
.await
.unwrap();

// Add a user message
backend
.add_conversation_message(conv_id, "user", "actual user message")
.await
.unwrap();

let convs = backend
.list_conversations_all_channels(user_id, 50)
.await
.unwrap();

let conv = convs.iter().find(|c| c.id == conv_id).unwrap();
assert_eq!(
conv.title.as_deref(),
Some("actual user message"),
"Message-derived title should take precedence over metadata title"
);
}
}
33 changes: 33 additions & 0 deletions src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,39 @@ pub trait ConversationStore: Send + Sync {
) -> Result<Option<String>, DatabaseError>;
}

/// Set a conversation title from user input if one hasn't been set yet.
///
/// Skips empty/whitespace-only input so that image-only or attachment-only
/// messages don't permanently block title-setting with an empty string.
/// Truncates to the first 100 characters for sidebar display.
pub async fn set_title_if_missing(
store: &(dyn ConversationStore + Send + Sync),
conversation_id: Uuid,
user_input: &str,
) {
let trimmed = user_input.trim();
if trimmed.is_empty() {
return;
}

let has_title = match store.get_conversation_metadata(conversation_id).await {
Ok(Some(meta)) => meta
.get("title")
.and_then(|t| t.as_str())
.is_some_and(|s| !s.is_empty()),
Ok(None) => false,
Err(_) => return,
};

if !has_title {
let title_text: String = trimmed.chars().take(100).collect();
let title_val = serde_json::json!(title_text);
let _ = store
.update_conversation_metadata_field(conversation_id, "title", &title_val)
.await;
}
}

#[async_trait]
pub trait JobStore: Send + Sync {
async fn save_job(&self, ctx: &JobContext) -> Result<(), DatabaseError>;
Expand Down
40 changes: 28 additions & 12 deletions src/history/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1839,12 +1839,20 @@ impl Store {
.and_then(|v| v.as_str())
.map(String::from);
let sql_title: Option<String> = r.get("title");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Filtering sql_title for empty strings ensures that the fallback logic correctly proceeds to check metadata.title or routine_name if the message-derived title is empty.

Suggested change
let sql_title: Option<String> = r.get("title");
let sql_title: Option<String> = r.get::<Option<String>, _>("title").filter(|s| !s.is_empty());

let title = sql_title.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
let title = sql_title
.or_else(|| {
metadata
.get("title")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
})
.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
ConversationSummary {
id: r.get("id"),
title,
Expand Down Expand Up @@ -1913,12 +1921,20 @@ impl Store {
// For routine/heartbeat threads, derive title from metadata
// since they may have no user messages.
let sql_title: Option<String> = r.get("title");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Filtering sql_title for empty strings ensures that the fallback logic correctly proceeds to check metadata.title or routine_name if the message-derived title is empty.

Suggested change
let sql_title: Option<String> = r.get("title");
let sql_title: Option<String> = r.get::<Option<String>, _>("title").filter(|s| !s.is_empty());

let title = sql_title.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
let title = sql_title
.or_else(|| {
metadata
.get("title")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(String::from)
})
.or_else(|| {
metadata
.get("routine_name")
.and_then(|v| v.as_str())
.map(String::from)
});
ConversationSummary {
id: r.get("id"),
title,
Expand Down
Loading