Skip to content
Merged
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
1 change: 1 addition & 0 deletions crates/ironclaw_gateway/static/js/core/bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ let logEventSource = null;
let currentTab = 'chat';
let currentThreadId = null;
let currentThreadIsReadOnly = false;
const threadChannelHints = new Map();
let assistantThreadId = null;
let hasMore = false;
let oldestTimestamp = null;
Expand Down
19 changes: 18 additions & 1 deletion crates/ironclaw_gateway/static/js/core/history.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ function loadHistory(before) {
apiFetch(historyUrl).then((data) => {
const container = document.getElementById('chat-messages');

if (!isPaginating && currentThreadId && data.channel) {
threadChannelHints.set(currentThreadId, data.channel);
}

if (!isPaginating) {
// Fresh load: clear and render
container.innerHTML = '';
Expand Down Expand Up @@ -117,6 +121,16 @@ function loadHistory(before) {
} else if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
showActivityThinking(ActivityEntry.t('activity.processing', 'Processing...'));
}
const hintedChannel = currentThreadId
? (data.channel || threadChannelHints.get(currentThreadId) || 'gateway')
: 'gateway';
currentThreadIsReadOnly = isReadOnlyChannel(hintedChannel);
if (currentThreadIsReadOnly) {
disableChatInputReadOnly();
} else {
enableChatInput();
}

if (data.pending_gate) {
handleGateRequired({
...data.pending_gate,
Expand Down Expand Up @@ -467,7 +481,10 @@ function loadThreads() {
const currentThread = currentThreadId === assistantThreadId
? data.assistant_thread
: threads.find(t => t.id === currentThreadId);
const ch = currentThread ? currentThread.channel : 'gateway';
const hintedChannel = currentThread
? currentThread.channel
: threadChannelHints.get(currentThreadId);
const ch = hintedChannel || 'gateway';
currentThreadIsReadOnly = isReadOnlyChannel(ch);
if (currentThreadIsReadOnly) {
disableChatInputReadOnly();
Expand Down
167 changes: 114 additions & 53 deletions src/channels/web/features/chat/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,7 @@ pub(crate) async fn chat_history_handler(
turns,
has_more,
oldest_timestamp,
channel: None,
pending_gate: history_pending_gate_info(&state, &user.user_id, thread_scope).await,
in_progress: None,
}));
Expand Down Expand Up @@ -559,6 +560,7 @@ pub(crate) async fn chat_history_handler(
turns,
has_more: false,
oldest_timestamp: None,
channel: None,
pending_gate,
in_progress: in_progress_from_thread(thread),
}));
Expand Down Expand Up @@ -588,6 +590,7 @@ pub(crate) async fn chat_history_handler(
turns,
has_more,
oldest_timestamp,
channel: None,
pending_gate: history_pending_gate_info(&state, &user.user_id, thread_scope).await,
in_progress,
}));
Expand All @@ -608,19 +611,18 @@ pub(crate) async fn chat_history_handler(
.enumerate()
.filter_map(|(index, entry)| engine_history_entry_to_message(thread_id, index, entry))
.collect();
if !synthetic.is_empty() {
let oldest_timestamp = synthetic.first().map(|m| m.created_at.to_rfc3339());
let mut turns = build_turns_from_db_messages(&synthetic);
enforce_generated_image_history_budget(&mut turns);
return Ok(Json(HistoryResponse {
thread_id,
turns,
has_more: false,
oldest_timestamp,
pending_gate: history_pending_gate_info(&state, &user.user_id, thread_scope).await,
in_progress: None,
}));
}
let oldest_timestamp = synthetic.first().map(|m| m.created_at.to_rfc3339());
let mut turns = build_turns_from_db_messages(&synthetic);
enforce_generated_image_history_budget(&mut turns);
return Ok(Json(HistoryResponse {
thread_id,
turns,
has_more: false,
oldest_timestamp,
channel: Some("engine".to_string()),
pending_gate: history_pending_gate_info(&state, &user.user_id, thread_scope).await,
in_progress: None,
}));
}

// Empty thread (just created, no messages yet)
Expand All @@ -639,6 +641,7 @@ pub(crate) async fn chat_history_handler(
turns: Vec::new(),
has_more: false,
oldest_timestamp: None,
channel: None,
pending_gate: history_pending_gate_info(&state, &user.user_id, thread_scope).await,
in_progress,
}))
Expand Down Expand Up @@ -723,46 +726,12 @@ pub(crate) async fn chat_threads_handler(
});
}

// Engine v2 threads for this user in the default project. These
// don't always get a matching v1 conversation row (the assistant
// flow dual-writes into the single assistant conv id, not the
// engine thread id), so without this merge they'd be invisible
// in the sidebar even though the chat history endpoint can now
// render them by id.
if let Ok(engine_threads) =
crate::bridge::list_engine_threads(None, &user.user_id).await
{
let existing_ids: std::collections::HashSet<uuid::Uuid> = threads
.iter()
.map(|t| t.id)
.chain(assistant_thread.as_ref().map(|a| a.id))
.collect();
for eng in engine_threads {
let Ok(uuid) = uuid::Uuid::parse_str(&eng.id) else {
continue;
};
if existing_ids.contains(&uuid) {
continue;
}
threads.push(ThreadInfo {
id: uuid,
state: eng.state,
turn_count: eng.step_count,
created_at: eng.created_at,
updated_at: eng.updated_at.clone(),
// Engine threads carry their goal as the only
// human-readable label; reuse it as the sidebar
// title so the user can tell threads apart.
title: Some(eng.goal),
thread_type: Some(eng.thread_type),
channel: Some("engine".to_string()),
});
}
// Re-sort by updated_at descending so engine threads interleave
// chronologically with v1 conversations.
threads.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
}

// Keep the chat sidebar scoped to persisted chat conversations.
// Engine v2 foreground threads are assistant execution internals
// and can rotate per message, so surfacing them here makes
// ordinary prompts look like standalone `engine` threads.
// Explicit engine-thread history still works via
// `chat_history_handler` when the caller already has a thread id.
let active_thread = session.lock().await.active_thread;

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

The session lock is acquired here to retrieve active_thread, but it was already locked and then dropped earlier in the same function (lines 657-663). While the drop was necessary to avoid holding the lock across the asynchronous database call at line 679, you could capture active_thread during the first lock period to avoid the redundant second lock acquisition.


return Ok(Json(ThreadListResponse {
Expand Down Expand Up @@ -1900,6 +1869,63 @@ mod tests {
assert_eq!(response.threads[0].channel.as_deref(), Some("gateway"));
}

#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_chat_threads_handler_hides_engine_threads_from_sidebar() {
let _lock = crate::bridge::test_support::ENGINE_STATE_TEST_LOCK
.lock()
.await;
crate::bridge::test_support::clear_engine_state().await;

let project_id =
crate::bridge::test_support::install_engine_state_with_threads(Vec::new()).await;
let mut thread = ironclaw_engine::Thread::new(
"assistant hello",
ironclaw_engine::ThreadType::Foreground,
project_id,
"alice",
ironclaw_engine::ThreadConfig::default(),
);
thread
.messages
.push(ironclaw_engine::ThreadMessage::user("hello"));
let engine_thread_id = thread.id.0;
crate::bridge::test_support::install_engine_state_with_threads(vec![thread]).await;

let (db, _tmp) = crate::testing::test_db().await;
let session_manager = Arc::new(SessionManager::new());
let state = test_gateway_state_with_store_and_session_manager(db, session_manager);

let response = chat_threads_handler(
axum::extract::State(state),
crate::channels::web::auth::AuthenticatedUser(UserIdentity {
user_id: "alice".to_string(),
role: "member".to_string(),
workspace_read_scopes: Vec::new(),
}),
)
.await
.expect("handler ok");

assert!(response.assistant_thread.is_some());
assert!(
response
.threads
.iter()
.all(|thread| thread.id != engine_thread_id),
"chat sidebar must not surface engine execution threads"
);
assert!(
response
.threads
.iter()
.all(|thread| thread.channel.as_deref() != Some("engine")),
"chat sidebar must stay scoped to chat conversations"
);

crate::bridge::test_support::clear_engine_state().await;
}

#[cfg(feature = "libsql")]
#[tokio::test]
async fn test_chat_new_thread_handler_persists_to_db_and_session() {
Expand Down Expand Up @@ -2266,11 +2292,46 @@ mod tests {
let turn = &response.turns[0];
assert_eq!(turn.user_input, "hello engine");
assert_eq!(turn.response.as_deref(), Some("hi back"));
assert_eq!(response.channel.as_deref(), Some("engine"));
assert!(!response.has_more);

crate::bridge::test_support::clear_engine_state().await;
}

#[tokio::test]
async fn test_chat_history_returns_engine_channel_hint_without_renderable_messages() {
let _lock = crate::bridge::test_support::ENGINE_STATE_TEST_LOCK
.lock()
.await;
crate::bridge::test_support::clear_engine_state().await;

let project_id =
crate::bridge::test_support::install_engine_state_with_threads(Vec::new()).await;
let thread = ironclaw_engine::Thread::new(
"empty engine thread",
ironclaw_engine::ThreadType::Foreground,
project_id,
"alice",
ironclaw_engine::ThreadConfig::default(),
);
let thread_uuid = thread.id.0;
crate::bridge::test_support::install_engine_state_with_threads(vec![thread]).await;

let mut state = test_gateway_state_with_dependencies(None, None, None, None);
Arc::get_mut(&mut state)
.expect("state should be uniquely owned")
.session_manager = Some(Arc::new(SessionManager::new()));

let (s, u, q) = history_request(state, "alice", thread_uuid);
let response = chat_history_handler(s, u, q).await.expect("history");

assert_eq!(response.thread_id, thread_uuid);
assert!(response.turns.is_empty());
assert_eq!(response.channel.as_deref(), Some("engine"));

crate::bridge::test_support::clear_engine_state().await;
}

#[tokio::test]
async fn test_chat_history_returns_404_for_cross_user_engine_thread() {
let _lock = crate::bridge::test_support::ENGINE_STATE_TEST_LOCK
Expand Down
4 changes: 4 additions & 0 deletions src/channels/web/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,10 @@ pub struct HistoryResponse {
/// Cursor for the next page (ISO8601 timestamp of the oldest message returned).
#[serde(skip_serializing_if = "Option::is_none")]
pub oldest_timestamp: Option<String>,
/// Channel hint for history views that are not present in the sidebar.
/// Used by the frontend to keep deep-linked non-gateway threads read-only.
#[serde(skip_serializing_if = "Option::is_none")]
pub channel: Option<String>,
/// Unified pending gate state for engine v2.
#[serde(skip_serializing_if = "Option::is_none")]
pub pending_gate: Option<PendingGateInfo>,
Expand Down
Loading
Loading