feat: let one source carry several chats, and open them with a +
Nightly Build / build (push) Successful in 7m49s
Nightly Build / build (push) Successful in 7m49s
A source had exactly one live conversation, so the copilot could only ever replace a chat, never add one: the trash button reset the source and the old conversation was left orphaned. Working on two things at once meant losing one. The tab bar now holds two kinds of tab. A primary tab is a source — it shows whatever `web` or `project-7` currently points at, which is where background delivery lands (notify, a finished async task, an inbound Telegram message) and what a reset moves to a fresh row. A secondary tab, opened with `+`, is one specific conversation: its source points elsewhere, so it is unreachable by source name and is addressed by id throughout — REST, WebSocket, event filtering. `POST /api/sessions/new` creates one without touching `sources`, which is the whole difference from a reset; its agent and run-context still come from the source, so an extra project tab is the coordinator with the project's context. Project "Open chat" is untouched and still resumes the project's own. The load-bearing half is in ChatHub: the input queue and the model pin are now keyed by session, not by source. Two tabs on one source would otherwise serialize into a single queue and a single turn, and share a `/model` pin — the odd one out, since the security group was already per-session and persisted. The source-taking methods survive as one-line resolvers, so Telegram, mobile and cron are untouched. Because queues now grow with conversations rather than with the handful of sources, a reset retires the queue it replaces instead of leaving a consumer task parked forever. Events are filtered per conversation, so anything a chat must see has to carry a session id: `show_file_to_user`'s OpenFile and the security-group revalidation were emitting untagged and would have reached nobody. A primary connection additionally follows NewSession for its source, so a second window does not keep talking to a conversation another window just reset. Tabs can be renamed by double-click — `chat_sessions.title` existed and was dead until now. An empty name stores NULL, so the box is also the undo.
This commit is contained in:
@@ -165,6 +165,23 @@ pub async fn session_tasks(
|
||||
let Some(session_id) = sources::active_session_id(&ctx.pool, &p.source).await? else {
|
||||
return Ok(Json(vec![]));
|
||||
};
|
||||
tasks_of_session(&ctx, session_id).await
|
||||
}
|
||||
|
||||
/// The same strip, addressed by conversation — what an extra copilot tab asks for.
|
||||
pub async fn session_tasks_by_id(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<SessionTaskResponse>>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
tasks_of_session(&ctx, id).await
|
||||
}
|
||||
|
||||
async fn tasks_of_session(
|
||||
ctx: &skald_core::skald::UserContext,
|
||||
session_id: i64,
|
||||
) -> Result<Json<Vec<SessionTaskResponse>>, ApiError> {
|
||||
let tasks = scheduled_jobs::list_for_parent_session(
|
||||
&ctx.pool, session_id, FAILED_TASK_WINDOW_MINUTES,
|
||||
).await?;
|
||||
|
||||
@@ -81,13 +81,29 @@ pub async fn session_task_inbox(
|
||||
Path(p): Path<super::cron::SourcePath>,
|
||||
) -> Result<Json<TaskInbox>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let empty = TaskInbox { approvals: vec![], clarifications: vec![] };
|
||||
|
||||
// A chat that has never run has no session, and therefore no tasks. Not an
|
||||
// error: the strip asks on every load, including the first one.
|
||||
let Some(session_id) = skald_core::db::sources::active_session_id(&ctx.pool, &p.source).await? else {
|
||||
return Ok(Json(empty));
|
||||
return Ok(Json(TaskInbox { approvals: vec![], clarifications: vec![] }));
|
||||
};
|
||||
task_inbox_of_session(&ctx, session_id).await
|
||||
}
|
||||
|
||||
/// The same inbox, addressed by conversation — what an extra copilot tab asks for.
|
||||
pub async fn session_task_inbox_by_id(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<TaskInbox>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
task_inbox_of_session(&ctx, id).await
|
||||
}
|
||||
|
||||
async fn task_inbox_of_session(
|
||||
ctx: &skald_core::skald::UserContext,
|
||||
session_id: i64,
|
||||
) -> Result<Json<TaskInbox>, ApiError> {
|
||||
let empty = TaskInbox { approvals: vec![], clarifications: vec![] };
|
||||
let children =
|
||||
skald_core::db::scheduled_jobs::running_child_sessions(&ctx.pool, session_id).await?;
|
||||
if children.is_empty() {
|
||||
|
||||
@@ -57,7 +57,16 @@ pub fn router() -> Router<Arc<Skald>> {
|
||||
// that opens/closes one. Static segment, so it takes precedence over the
|
||||
// `/sessions/{id}` detail route below.
|
||||
.route("/sessions/open", get(sessions::list_open_tabs))
|
||||
.route("/sessions/new", post(sessions::create_additional))
|
||||
.route("/sessions/{id}/open", put(sessions::set_open))
|
||||
.route("/sessions/{id}/title", put(sessions::set_title))
|
||||
// The session-addressed twins of the `/{source}/…` chat routes below: an
|
||||
// extra tab is not the session its source points at, so it cannot be
|
||||
// reached through the source at all.
|
||||
.route("/sessions/{id}/messages", get(sessions::session_messages))
|
||||
.route("/sessions/{id}/tasks", get(cron::session_tasks_by_id))
|
||||
.route("/sessions/{id}/inbox", get(inbox::session_task_inbox_by_id))
|
||||
.route("/sessions/{id}/uploads", post(uploads::upload_to_session).layer(DefaultBodyLimit::disable()))
|
||||
// System agents (event triage, memory lints) — the caller's own run history, plus
|
||||
// the agent list (settings included only for an admin).
|
||||
.route("/system-agents", get(system_agents::list_agents))
|
||||
|
||||
@@ -66,14 +66,24 @@ pub async fn create(
|
||||
// encrypted file instead of a per-origin store a second household member shares.
|
||||
// *Which* tab is selected stays client-side — that one is per window.
|
||||
|
||||
/// One restored tab. `label` is resolved here so the client needs a single round
|
||||
/// trip, and so a project tab shows the project's *current* name rather than the
|
||||
/// one cached when it was opened.
|
||||
/// One restored tab.
|
||||
///
|
||||
/// `label` is resolved here so the client needs a single round trip, and so a
|
||||
/// project tab shows the project's *current* name rather than the one cached when
|
||||
/// it was opened. A user-set `title` always wins over it.
|
||||
///
|
||||
/// `primary` is the discriminator between the two kinds of tab: a primary one *is*
|
||||
/// the session its source currently points at — background delivery reaches it,
|
||||
/// and a reset moves it to a new row — while a secondary one is addressable only
|
||||
/// by id. Computed here rather than guessed client-side, because `sources` is the
|
||||
/// only thing that knows.
|
||||
#[derive(Serialize)]
|
||||
pub struct OpenTab {
|
||||
pub session_id: i64,
|
||||
pub source: String,
|
||||
pub label: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
pub async fn list_open_tabs(
|
||||
@@ -85,20 +95,72 @@ pub async fn list_open_tabs(
|
||||
|
||||
let mut tabs = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
// The General tab is always rendered and never closable, so it is not a
|
||||
// stored tab; a row claiming otherwise would render a duplicate of it.
|
||||
if row.source == DEFAULT_WEB_SOURCE {
|
||||
let active = sources::active_session_id(&ctx.pool, &row.source).await?;
|
||||
let primary = active == Some(row.id);
|
||||
// The General tab is always rendered and never closable, so the primary
|
||||
// `web` conversation is not a stored tab; a row for it would duplicate it.
|
||||
// A *secondary* `web` conversation is an extra general chat and belongs here.
|
||||
if primary && row.source == DEFAULT_WEB_SOURCE {
|
||||
continue;
|
||||
}
|
||||
let label = match row.title {
|
||||
let label = match row.title.clone() {
|
||||
Some(t) => Some(t),
|
||||
None => project_label(&skald, &row.source).await,
|
||||
};
|
||||
tabs.push(OpenTab { session_id: row.id, source: row.source, label });
|
||||
tabs.push(OpenTab { session_id: row.id, source: row.source, label, title: row.title, primary });
|
||||
}
|
||||
Ok(Json(tabs))
|
||||
}
|
||||
|
||||
// ── POST /api/sessions/new — one more conversation, not a reset ───────────────
|
||||
|
||||
/// Open an **additional** conversation on a source and show it as a tab.
|
||||
///
|
||||
/// Unlike `POST /api/sessions` (which resets: the source's pointer moves and the
|
||||
/// old conversation is left behind), this leaves `sources.active_session_id`
|
||||
/// alone. The agent and run-context still come from the source, so an extra tab
|
||||
/// on a project is the coordinator with the project's context, and an extra
|
||||
/// General one is the caller's role-assigned assistant.
|
||||
pub async fn create_additional(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Query(q): Query<CreateQuery>,
|
||||
) -> Result<Json<OpenTab>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
let (agent, rc) = super::projects::provisioning_for_source(&skald, &auth.user_id, &q.source).await?;
|
||||
let rc = match rc {
|
||||
Some(rc) => Some(rc),
|
||||
None => role_default_run_context(&skald, &auth.user_id).await?,
|
||||
};
|
||||
let session_id = ctx.chat_hub.create_additional_session(&q.source, &agent, rc.as_ref()).await?;
|
||||
chat_sessions::set_open(&ctx.pool, session_id, true).await?;
|
||||
Ok(Json(OpenTab {
|
||||
session_id,
|
||||
label: project_label(&skald, &q.source).await,
|
||||
source: q.source,
|
||||
title: None,
|
||||
primary: false,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── PUT /api/sessions/{id}/title — rename a tab ───────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SetTitleBody {
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn set_title(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
Json(body): Json<SetTitleBody>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
chat_sessions::set_title(&ctx.pool, id, body.title.as_deref()).await?;
|
||||
Ok(Json(json!({})))
|
||||
}
|
||||
|
||||
/// The display name of a project source, or `None` for anything else. Membership
|
||||
/// is deliberately not re-checked: the conversation is the caller's own and stays
|
||||
/// readable even if they left the project — it is *sending* into it that has to
|
||||
@@ -171,14 +233,32 @@ pub async fn source_messages(
|
||||
messages_for_source(&skald, &ctx, &p.source).await
|
||||
}
|
||||
|
||||
// ── GET /api/sessions/{id}/messages ───────────────────────────────────────────
|
||||
//
|
||||
// The same history, addressed by conversation instead of by source — what an
|
||||
// extra copilot tab reads, since it is not the session its source points at.
|
||||
|
||||
pub async fn session_messages(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
) -> Result<Json<Vec<Value>>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
messages_for_session(&skald, &ctx, id).await
|
||||
}
|
||||
|
||||
async fn messages_for_source(skald: &Arc<Skald>, ctx: &UserContext, source: &str) -> Result<Json<Vec<Value>>, ApiError> {
|
||||
// History/sessions read from the caller's own pool; the tool registry is a
|
||||
// global capability, and approval is this user's per-user manager.
|
||||
let db = &ctx.pool;
|
||||
let session_id = match sources::active_session_id(db, source).await? {
|
||||
let session_id = match sources::active_session_id(&ctx.pool, source).await? {
|
||||
Some(id) => id,
|
||||
None => return Ok(Json(vec![])),
|
||||
};
|
||||
messages_for_session(skald, ctx, session_id).await
|
||||
}
|
||||
|
||||
async fn messages_for_session(skald: &Arc<Skald>, ctx: &UserContext, session_id: i64) -> Result<Json<Vec<Value>>, ApiError> {
|
||||
// History/sessions read from the caller's own pool; the tool registry is a
|
||||
// global capability, and approval is this user's per-user manager.
|
||||
let db = &ctx.pool;
|
||||
|
||||
let main_stack = match chat_sessions_stack::main_for_session(db, session_id).await? {
|
||||
Some(s) => s,
|
||||
|
||||
@@ -35,10 +35,37 @@ pub async fn upload(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(p): Path<SourcePath>,
|
||||
mut multipart: Multipart,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Vec<Attachment>>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
save_all(&ctx, Target::Source(p.source), multipart).await
|
||||
}
|
||||
|
||||
/// `POST /api/sessions/{id}/uploads` — the same thing addressed by conversation,
|
||||
/// so a file dropped into an extra copilot tab lands in *that* conversation's
|
||||
/// upload directory and not in whichever one its source currently points at.
|
||||
pub async fn upload_to_session(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
Extension(auth): Extension<AuthUser>,
|
||||
Path(id): Path<i64>,
|
||||
multipart: Multipart,
|
||||
) -> Result<Json<Vec<Attachment>>, ApiError> {
|
||||
let ctx = require_context(&skald, &auth.user_id).await?;
|
||||
save_all(&ctx, Target::Session(id), multipart).await
|
||||
}
|
||||
|
||||
/// Which conversation an upload belongs to: named indirectly through its source,
|
||||
/// or directly. Both end on the same seam.
|
||||
enum Target {
|
||||
Source(String),
|
||||
Session(i64),
|
||||
}
|
||||
|
||||
async fn save_all(
|
||||
ctx: &skald_core::skald::UserContext,
|
||||
target: Target,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<Json<Vec<Attachment>>, ApiError> {
|
||||
let mut saved: Vec<Attachment> = Vec::new();
|
||||
|
||||
while let Some(mut field) = multipart.next_field().await
|
||||
@@ -63,7 +90,12 @@ pub async fn upload(
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
let att = ctx.chat_hub.save_upload(&p.source, &orig_name, client_mime, &bytes).await?;
|
||||
let att = match &target {
|
||||
Target::Source(source) =>
|
||||
ctx.chat_hub.save_upload(source, &orig_name, client_mime, &bytes).await?,
|
||||
Target::Session(id) =>
|
||||
ctx.chat_hub.save_upload_to_session(*id, &orig_name, client_mime, &bytes).await?,
|
||||
};
|
||||
saved.push(att);
|
||||
}
|
||||
|
||||
|
||||
+83
-49
@@ -23,6 +23,11 @@ use super::guard::AuthUser;
|
||||
#[derive(Deserialize)]
|
||||
pub struct WsParams {
|
||||
source: Option<String>,
|
||||
/// Address one specific conversation instead of "whatever this source points
|
||||
/// at". The copilot's extra tabs use it: they are open conversations on a
|
||||
/// source whose pointer names a different one, so they are unreachable by
|
||||
/// source name alone.
|
||||
session: Option<i64>,
|
||||
}
|
||||
|
||||
const WEB_FORMAT_CONTEXT: &str = "\
|
||||
@@ -75,12 +80,18 @@ pub async fn handler(
|
||||
State(skald): State<Arc<Skald>>,
|
||||
) -> impl IntoResponse {
|
||||
let source = params.source.unwrap_or_else(|| "web".to_string());
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, skald, source, auth.user_id))
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, skald, source, params.session, auth.user_id))
|
||||
}
|
||||
|
||||
// ── Socket loop ───────────────────────────────────────────────────────────────
|
||||
|
||||
async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String, user_id: String) {
|
||||
async fn handle_socket(
|
||||
mut socket: WebSocket,
|
||||
skald: Arc<Skald>,
|
||||
source: String,
|
||||
session: Option<i64>,
|
||||
user_id: String,
|
||||
) {
|
||||
// Resolve the caller's per-user runtime. The pool is unlocked at login, so an
|
||||
// authenticated connection normally has a context; a missing one means the
|
||||
// database re-locked (e.g. a restart with no re-login) — report and close.
|
||||
@@ -97,15 +108,25 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
// sessions land in their `{userid}.db` and events never cross to another user.
|
||||
let chat_hub: Arc<ChatHub> = Arc::clone(&ctx.chat_hub);
|
||||
|
||||
let session_handler = match chat_hub.session_handler(&source).await {
|
||||
// Two ways in, one binding out. A source-addressed connection is **primary**:
|
||||
// it follows its source, so a reset elsewhere moves it to the new conversation
|
||||
// (see the `NewSession` case below). A session-addressed one is pinned to the
|
||||
// conversation it named and ignores what the source does.
|
||||
let primary = session.is_none();
|
||||
let resolved = match session {
|
||||
Some(id) => chat_hub.handler_for_session(id).await,
|
||||
None => chat_hub.session_handler(&source).await,
|
||||
};
|
||||
let mut session_handler = match resolved {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
let _ = socket.send(to_msg(&ServerEvent::Error { message: e.to_string() })).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut session_id = session_handler.session_id;
|
||||
|
||||
info!(source, user = %user_id, "WebSocket connected");
|
||||
info!(source, session_id, primary, user = %user_id, "WebSocket connected");
|
||||
|
||||
let mut rx = chat_hub.events(&source);
|
||||
|
||||
@@ -120,7 +141,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
// the chat picker starts in sync. The twin of the model pill — but the group is
|
||||
// per-session persisted, not a per-source RAM pin, so it must be sent on connect.
|
||||
let _ = socket.send(to_msg(&ServerEvent::SecurityGroupSelected {
|
||||
group: current_session_group(&ctx.pool, &source).await,
|
||||
group: current_session_group(&ctx.pool, session_id).await,
|
||||
})).await;
|
||||
|
||||
// Keepalive: a long, silent turn (e.g. a slow `execute_cmd` producing no
|
||||
@@ -145,12 +166,11 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
|
||||
// ── resume ────────────────────────────────────────────────────
|
||||
if is_resume_msg(&text) {
|
||||
info!("web WS: resume requested");
|
||||
info!(session_id, "web WS: resume requested");
|
||||
let hub = Arc::clone(&chat_hub);
|
||||
let src = source.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = hub.resume(&src).await {
|
||||
tracing::error!(error = %e, source = %src, "resume failed");
|
||||
if let Err(e) = hub.resume_for_session(session_id).await {
|
||||
tracing::error!(error = %e, session_id, "resume failed");
|
||||
}
|
||||
});
|
||||
continue;
|
||||
@@ -167,8 +187,8 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
if handle_approval_msg(&text, &chat_hub).await { continue; }
|
||||
if handle_question_answer_msg(&text, &session_handler).await { continue; }
|
||||
if handle_data_msg(&text, &skald) { continue; }
|
||||
if handle_select_client_msg(&text, &source, &chat_hub).await { continue; }
|
||||
if handle_select_security_group_msg(&text, &source, &user_id, &skald, &ctx, &session_handler).await { continue; }
|
||||
if handle_select_client_msg(&text, session_id, &chat_hub).await { continue; }
|
||||
if handle_select_security_group_msg(&text, &source, session_id, &user_id, &skald, &ctx, &session_handler).await { continue; }
|
||||
|
||||
// ── /sethome ──────────────────────────────────────────────────
|
||||
let client_msg: ClientMessage = match serde_json::from_str(&text) {
|
||||
@@ -212,7 +232,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
}
|
||||
|
||||
if cmd == "/context" {
|
||||
match chat_hub.context_info(&source).await {
|
||||
match chat_hub.context_info_for_session(session_id).await {
|
||||
Ok((input, output)) => {
|
||||
let input_str = input.map_or("?".to_string(), |t| t.to_string());
|
||||
let output_str = output.map_or("?".to_string(), |t| t.to_string());
|
||||
@@ -233,7 +253,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
}
|
||||
|
||||
if cmd == "/cost" {
|
||||
match chat_hub.cost_info(&source).await {
|
||||
match chat_hub.cost_info_for_session(session_id).await {
|
||||
Ok(Some(c)) => {
|
||||
let _ = socket.send(to_msg(&ServerEvent::Done {
|
||||
message_id: 0,
|
||||
@@ -262,7 +282,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
}
|
||||
|
||||
if cmd == "/compact" {
|
||||
match chat_hub.force_compact(&source).await {
|
||||
match chat_hub.force_compact_for_session(session_id).await {
|
||||
Ok(true) => {
|
||||
let _ = socket.send(to_msg(&ServerEvent::Done {
|
||||
message_id: 0,
|
||||
@@ -291,7 +311,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
}
|
||||
|
||||
if cmd == "/resettools" {
|
||||
match chat_hub.reset_mcp(&source).await {
|
||||
match chat_hub.reset_mcp_for_session(session_id).await {
|
||||
Ok(()) => {
|
||||
let _ = socket.send(to_msg(&ServerEvent::Done {
|
||||
message_id: 0,
|
||||
@@ -310,7 +330,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
}
|
||||
|
||||
if cmd == "/models" {
|
||||
let items = chat_hub.list_clients_marked(&source).await;
|
||||
let items = chat_hub.list_clients_marked_for_session(session_id).await;
|
||||
let content = format_models_md(&items);
|
||||
let _ = socket.send(to_msg(&ServerEvent::Done {
|
||||
message_id: 0,
|
||||
@@ -324,7 +344,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
}
|
||||
|
||||
if let Some(arg) = cmd.strip_prefix("/model").map(str::trim) {
|
||||
let outcome = chat_hub.apply_model_command(&source, arg).await;
|
||||
let outcome = chat_hub.apply_model_command_for_session(session_id, arg).await;
|
||||
let content = match outcome {
|
||||
ModelCommandOutcome::Set(name) => format!("✅ Model set: **{name}**"),
|
||||
ModelCommandOutcome::Cleared => "✅ Model reset to **auto**.".to_string(),
|
||||
@@ -402,7 +422,7 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
// client lives in ChatHub.selected_clients[source]. The web
|
||||
// `/model` command and the dropdown both flow through
|
||||
// set_selected_client, which broadcasts ClientSelected.
|
||||
client_name: chat_hub.get_selected_client(&source).await,
|
||||
client_name: chat_hub.get_selected_client_for_session(session_id).await,
|
||||
extra_system_context: Some(WEB_FORMAT_CONTEXT.to_string()),
|
||||
// `show_file_to_user` used to be injected right here, per
|
||||
// message — which is why it disappeared from a conversation
|
||||
@@ -413,8 +433,8 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
};
|
||||
// send_message only enqueues — the turn runs on ChatHub's per-source
|
||||
// consumer — so awaiting inline keeps this WS read loop responsive.
|
||||
if let Err(e) = chat_hub.send_message(&source, &content, opts).await {
|
||||
tracing::error!(error = %e, source = %source, "send_message enqueue failed");
|
||||
if let Err(e) = chat_hub.send_message_to_session(session_id, &content, opts).await {
|
||||
tracing::error!(error = %e, session_id, "send_message enqueue failed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,14 +442,32 @@ async fn handle_socket(mut socket: WebSocket, skald: Arc<Skald>, source: String,
|
||||
event = rx.recv() => {
|
||||
match event {
|
||||
Ok(ge) => {
|
||||
// Forward events for this connection's source.
|
||||
// The inbox lifecycle events (approval/clarification/
|
||||
// elicitation requested+resolved) are forwarded regardless
|
||||
// of source: they carry no content — just ids — and let the
|
||||
// sidebar badge and inbox pages refresh live when any of
|
||||
// this user's sessions (chat, cron, background) raises or
|
||||
// settles a pending item.
|
||||
let forward = ge.source.as_deref() == Some(source.as_str())
|
||||
// A reset elsewhere replaced this source's conversation. A
|
||||
// primary connection follows it — otherwise a second window
|
||||
// would keep talking to the discarded one, and its own
|
||||
// `/new` would be the only way back. A session-addressed
|
||||
// connection ignores it: it was pinned on purpose.
|
||||
if primary
|
||||
&& ge.source.as_deref() == Some(source.as_str())
|
||||
&& let ServerEvent::NewSession { session_id: new_id } = ge.event
|
||||
&& new_id != session_id
|
||||
{
|
||||
if let Ok(h) = chat_hub.handler_for_session(new_id).await {
|
||||
session_handler = h;
|
||||
session_id = new_id;
|
||||
info!(source, session_id, "web WS: followed source to its new conversation");
|
||||
}
|
||||
}
|
||||
|
||||
// Events are forwarded per **conversation**, not per source:
|
||||
// two tabs can share a source and must not see each other's
|
||||
// turns. The inbox lifecycle events (approval/clarification/
|
||||
// elicitation requested+resolved) are the exception and go to
|
||||
// everyone — they carry no content, just ids, and let the
|
||||
// sidebar badge and inbox pages refresh live when any of this
|
||||
// user's sessions (chat, cron, background) raises or settles a
|
||||
// pending item.
|
||||
let forward = ge.session_id == Some(session_id)
|
||||
|| matches!(ge.event,
|
||||
ServerEvent::ApprovalRequested { .. }
|
||||
| ServerEvent::ApprovalResolved { .. }
|
||||
@@ -522,18 +560,18 @@ async fn handle_question_answer_msg(
|
||||
/// via `set_selected_client`, which broadcasts `ClientSelected` to every client
|
||||
/// of the source (so all open tabs/mobile update).
|
||||
async fn handle_select_client_msg(
|
||||
text: &str,
|
||||
source: &str,
|
||||
chat_hub: &Arc<skald_core::chat_hub::ChatHub>,
|
||||
text: &str,
|
||||
session_id: i64,
|
||||
chat_hub: &Arc<skald_core::chat_hub::ChatHub>,
|
||||
) -> bool {
|
||||
let Ok(v) = serde_json::from_str::<Value>(text) else { return false };
|
||||
if v["type"].as_str() != Some("select_client") { return false }
|
||||
let Some(client) = v["client"].as_str() else { return false };
|
||||
let client = client.to_string();
|
||||
if client == "auto" {
|
||||
chat_hub.clear_selected_client(source).await;
|
||||
chat_hub.clear_selected_client_for_session(session_id).await;
|
||||
} else {
|
||||
chat_hub.set_selected_client(source, client).await;
|
||||
chat_hub.set_selected_client_for_session(session_id, client).await;
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -548,6 +586,7 @@ async fn handle_select_client_msg(
|
||||
async fn handle_select_security_group_msg(
|
||||
text: &str,
|
||||
source: &str,
|
||||
session_id: i64,
|
||||
user_id: &str,
|
||||
skald: &Arc<Skald>,
|
||||
ctx: &Arc<skald_core::skald::UserContext>,
|
||||
@@ -576,14 +615,12 @@ async fn handle_select_security_group_msg(
|
||||
};
|
||||
|
||||
// Persist on the session row (owner pool) and update the live handler.
|
||||
if let Ok(Some(sid)) = skald_core::db::sources::active_session_id(&ctx.pool, source).await {
|
||||
let _ = skald_core::db::chat_sessions::set_run_context(
|
||||
&ctx.pool,
|
||||
sid,
|
||||
effective.as_ref().map(|c| c.to_db()).as_deref(),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let _ = skald_core::db::chat_sessions::set_run_context(
|
||||
&ctx.pool,
|
||||
session_id,
|
||||
effective.as_ref().map(|c| c.to_db()).as_deref(),
|
||||
)
|
||||
.await;
|
||||
session_handler.set_run_context(effective.clone()).await;
|
||||
|
||||
// Broadcast the effective group id ("default" when cleared) to every client.
|
||||
@@ -593,20 +630,17 @@ async fn handle_select_security_group_msg(
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
ctx.chat_hub.emit(skald_core::events::GlobalEvent {
|
||||
source: Some(source.to_string()),
|
||||
session_id: None,
|
||||
session_id: Some(session_id),
|
||||
event: ServerEvent::SecurityGroupSelected { group },
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// The active session's current security-group for `source`, or `"default"` when
|
||||
/// no session or no run-context is set. Used to seed a freshly-connected client.
|
||||
async fn current_session_group(pool: &sqlx::SqlitePool, source: &str) -> String {
|
||||
/// A conversation's current security-group, or `"default"` when it has no
|
||||
/// run-context set. Used to seed a freshly-connected client.
|
||||
async fn current_session_group(pool: &sqlx::SqlitePool, session_id: i64) -> String {
|
||||
use skald_core::run_context::RunContext;
|
||||
let Ok(Some(sid)) = skald_core::db::sources::active_session_id(pool, source).await else {
|
||||
return "default".to_string();
|
||||
};
|
||||
let group = skald_core::db::chat_sessions::find_by_id(pool, sid)
|
||||
let group = skald_core::db::chat_sessions::find_by_id(pool, session_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
|
||||
@@ -61,6 +61,7 @@ impl WebFrontend {
|
||||
vec![skald_core::tools::show_file::make_tool(
|
||||
hub,
|
||||
source.to_string(),
|
||||
handler.session_id,
|
||||
handler.shared_fs(),
|
||||
handler.owner_pool().as_ref().clone(),
|
||||
handler.shared_pool().as_ref().clone(),
|
||||
|
||||
Reference in New Issue
Block a user