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:
@@ -447,7 +447,13 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/
|
||||
|
||||
**The chat is the home page.** `<app-copilot>` is a single persistent element with two layout modes driven by the route (`llm-page-change`): `mode="full"` on the home route (it fills the workspace — the conversation IS the landing page, with a welcome hero + prompt suggestions as its empty state) and `mode="dock"` on every other route (the classic resizable side panel). Same element ⇒ WS, tabs, scroll and drafts survive navigation; you watch files/projects update live while the conversation keeps going. Collapse only applies to the dock. The old dashboard content (hero, LLM stats charts, pending inbox, quick guide) lives on as the separate `#dashboard` page; the debug toggle moved to the Settings page.
|
||||
|
||||
**The tab bar is server-side state; the selection is not.** Which conversations the copilot shows survives a reload through `chat_sessions.is_open` (owner table, additive via `ensure_column`) — `GET /api/sessions/open` restores them, `PUT /api/sessions/{id}/open` opens/closes one. It is deliberately *not* localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, and in the user's own encrypted file the set follows them across devices instead. **Which** tab is selected stays in `sessionStorage` (`copilot-active-tab`), because that one is per browser window — a shared value would have two windows fighting over it and turn every tab click into a write. Three consequences that are easy to get wrong: (a) `is_open` defaults to **0** and `chat_sessions::create` never sets it — every `/new` leaves its predecessor behind and every system-agent pass mints a row, so `DEFAULT 1` would restore a bar full of conversations nobody opened; only the copilot writes the column. (b) The General tab is never stored — it exists because the copilot exists. (c) A reset **moves** the flag: `provision_session(reset)` mints a new row, so `POST /api/sessions` returns the new id and the `new_session` event carries it, and `_bindTabSession` closes the old row as it opens the new one — leaving both would restore the source twice and let a later close clear the stale one. Restoring the selection happens *before* `super.connectedCallback()` (sessionStorage is synchronous) so the first paint doesn't fetch General and throw it away; the set arrives over the network and reconciles after, awaiting the base's initial connection so it never opens a second WS.
|
||||
**Two kinds of tab, and the difference is what a tab names.** A **primary** tab is a *source*: it shows whatever `web` / `project-7` currently points at (`sources.active_session_id`), which is also where background delivery lands — `notify`, a finished async task, an inbound Telegram message — and what a `/new` moves to a fresh row. At most one per source; a project's **Open chat** always lands on it and never mints a conversation (`provision_session(reset:false)`). A **secondary** tab is one specific conversation, opened with `+`: its source points elsewhere, so it is **unreachable by source name** and is addressed by id everywhere — REST, WebSocket, event filtering. Nothing is delivered to it from outside. `POST /api/sessions/new` creates one *without touching `sources`*, which is the entire difference from `POST /api/sessions` (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.
|
||||
|
||||
**The queue and the model pin are keyed by conversation, not by source** (`ChatHub.inboxes: HashMap<i64, ConversationInbox>`, `selected_clients: HashMap<i64, String>`). This is the load-bearing half: two tabs on one source would otherwise serialize into one queue and one turn, and share a `/model` pin — while the *security group* was already per-session and persisted, so the pin was the odd one out. The source-taking methods survive as one-line resolvers (`send_message` → `send_message_to_session`, and `_for_session` twins for context/cost/compact/mcp/model/cancel/resume/upload), so Telegram, mobile and cron are untouched. Cost of the rekey: queues now grow with conversations-talked-to-since-boot rather than with the four-or-five sources, so a reset **retires** the queue it replaces (`retire_inbox` → `ConversationInbox::close`, consumer breaks) instead of leaving a parked task forever.
|
||||
|
||||
**Events are filtered per conversation** (`ge.session_id == Some(session_id)`), which is why anything a chat must see has to carry a session id — an untagged `GlobalEvent` now reaches nobody. Two emitters had to be fixed for exactly that: `show_file_to_user`'s `OpenFile` (the tool takes a `session_id` from `handler.session_id` via the interface-tools builder) and `revalidate_security_groups`, which now returns `(session_id, source, group)`. The inbox lifecycle events (`Approval*`/`Clarification*`/`Elicitation*`) stay the deliberate exception and go to every connection, since they carry ids only and drive the sidebar badge. A **primary** WS connection additionally follows `NewSession` for its source — re-binding `session_id` and its handler mid-loop — so a second window doesn't keep talking to a conversation another window just reset; a session-addressed one ignores it, having been pinned on purpose.
|
||||
|
||||
**The tab bar is server-side state; the selection is not.** Which conversations the copilot shows survives a reload through `chat_sessions.is_open` (owner table, additive via `ensure_column`) — `GET /api/sessions/open` restores them (computing `primary` per row, since only `sources` knows), `PUT /api/sessions/{id}/open` opens/closes one, `PUT /api/sessions/{id}/title` renames one (`title` predated all this and was dead; an empty title stores `NULL`, so the rename box is also the undo). It is deliberately *not* localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, and in the user's own encrypted file the set follows them across devices instead. **Which** tab is selected stays in `sessionStorage` (`copilot-active-tab`), because that one is per browser window — a shared value would have two windows fighting over it and turn every tab click into a write. Three consequences that are easy to get wrong: (a) `is_open` defaults to **0** and `chat_sessions::create` never sets it — every `/new` leaves its predecessor behind and every system-agent pass mints a row, so `DEFAULT 1` would restore a bar full of conversations nobody opened; only the copilot writes the column. (b) The General tab is never stored — it exists because the copilot exists. (c) A reset **moves** the flag: `provision_session(reset)` mints a new row, so `POST /api/sessions` returns the new id and the `new_session` event carries it, and `_bindTabSession` closes the old row as it opens the new one — leaving both would restore the source twice and let a later close clear the stale one. Restoring the selection happens *before* `super.connectedCallback()` (sessionStorage is synchronous) so the first paint doesn't fetch General and throw it away; the set arrives over the network and reconciles after, awaiting the base's initial connection so it never opens a second WS.
|
||||
|
||||
**Theme** (`web/css/variables.css`): warm "paper" palette (terracotta accent, light by default, warm-charcoal dark), generous radius (`--radius-sm/md/lg`), 16px-base chat type, WCAG-fixed contrasts, global `:focus-visible` ring and `prefers-reduced-motion` support. Everything consumes CSS variables — never hardcode a hex in a component stylesheet.
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
//! Per-source input inbox for ChatHub.
|
||||
//! Per-conversation input inbox for ChatHub.
|
||||
//!
|
||||
//! Each interactive source (telegram, web, mobile…) gets one `SourceInbox` and a
|
||||
//! single consumer task (spawned lazily in `ChatHub`). A single consumer per
|
||||
//! source makes delivery strictly FIFO, removing the ordering race of the old
|
||||
//! detached-spawn dispatch.
|
||||
//! Each conversation gets one `ConversationInbox` and a single consumer task
|
||||
//! (spawned lazily in `ChatHub`). A single consumer per conversation makes
|
||||
//! delivery strictly FIFO, removing the ordering race of the old detached-spawn
|
||||
//! dispatch.
|
||||
//!
|
||||
//! The key is the **session**, not the source it answers on. A source used to be
|
||||
//! close enough — it had exactly one live session — but the copilot can now hold
|
||||
//! several conversations on the same source, and keying the queue by source would
|
||||
//! serialize two of them into one turn on whichever session the source points at.
|
||||
//!
|
||||
//! Messages are kept as **individual** units — they are not coalesced here. The
|
||||
//! consumer pops one to seed a turn (`build_unit`); any further messages that
|
||||
@@ -17,7 +22,7 @@
|
||||
//! `ChatSessionHandler.processing`; this inbox sits in front of it, adding ordering.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
use tokio::sync::{Mutex, Notify};
|
||||
|
||||
@@ -30,14 +35,33 @@ pub(super) struct QueuedMessage {
|
||||
pub opts: SendMessageOptions,
|
||||
}
|
||||
|
||||
/// Pending queue + wake signal for a single source.
|
||||
/// Pending queue + wake signal for a single conversation.
|
||||
#[derive(Default)]
|
||||
pub(super) struct SourceInbox {
|
||||
pub(super) struct ConversationInbox {
|
||||
pub pending: Mutex<VecDeque<QueuedMessage>>,
|
||||
pub notify: Notify,
|
||||
/// Bumped by `ChatHub::cancel` (after clearing `pending`) so the consumer can
|
||||
/// drop a unit it drained microseconds before a `/stop`.
|
||||
pub cancel_epoch: AtomicU64,
|
||||
/// Set when the conversation this queue belongs to is gone for good (a reset
|
||||
/// replaced it), so its consumer task stops instead of parking forever.
|
||||
///
|
||||
/// Keying queues by conversation rather than by source means their number
|
||||
/// grows with conversations talked to since boot, not with the four or five
|
||||
/// sources — so a queue that can never receive again has to be able to end.
|
||||
closed: AtomicBool,
|
||||
}
|
||||
|
||||
impl ConversationInbox {
|
||||
/// Retire this queue and wake its consumer so it observes the flag.
|
||||
pub fn close(&self) {
|
||||
self.closed.store(true, Ordering::Release);
|
||||
self.notify.notify_one();
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.closed.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
/// Pops the next dispatch unit from `pending` — a **single** message, used by the
|
||||
|
||||
@@ -11,7 +11,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
mod inbox;
|
||||
use inbox::{QueuedMessage, SourceInbox, build_unit, drain_leading_user};
|
||||
use inbox::{ConversationInbox, QueuedMessage, build_unit, drain_leading_user};
|
||||
|
||||
use crate::approval::ApprovalManager;
|
||||
use crate::cron::TaskManager;
|
||||
@@ -61,9 +61,12 @@ pub type InterfaceToolsBuilder = Arc<
|
||||
|
||||
// ── ChatHub ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Manages **interactive, user-facing sessions only** (web, mobile, project chats):
|
||||
/// one live, persistent session per `source`, reachable over WebSocket and addressed
|
||||
/// by source id through the `sources` table.
|
||||
/// Manages **interactive, user-facing sessions only** (web, mobile, project chats),
|
||||
/// reachable over WebSocket and addressed either by `source` — through the `sources`
|
||||
/// table, which names the one conversation per source that background delivery
|
||||
/// reaches — or directly by session id, for the extra conversations a source can
|
||||
/// carry (the copilot's `+` tabs). The queues and pins below are keyed by the
|
||||
/// latter: a source resolves to a conversation, it is not one.
|
||||
///
|
||||
/// It is **not** a runner for background / non-interactive agents (cron jobs, event
|
||||
/// triage, sub-agent tasks). Those go through `TaskManager` / `ChatSessionManager`
|
||||
@@ -86,19 +89,27 @@ pub struct ChatHub {
|
||||
/// The surface's own interface tools, installed post-construction by the
|
||||
/// shell. See [`InterfaceToolsBuilder`].
|
||||
iface_tools: OnceLock<InterfaceToolsBuilder>,
|
||||
/// Per-source input inboxes (coalescing + FIFO ordering). Created lazily on the
|
||||
/// first message for a source; each spawns one consumer task.
|
||||
inboxes: Mutex<HashMap<String, Arc<SourceInbox>>>,
|
||||
/// Weak self-reference, set in `new()`, so lazily-spawned source consumers can
|
||||
/// Per-conversation input inboxes (coalescing + FIFO ordering). Created lazily
|
||||
/// on the first message for a session; each spawns one consumer task.
|
||||
///
|
||||
/// Keyed by **session id**, not by source: a source can now carry several open
|
||||
/// conversations at once (the copilot's extra tabs), and one queue per source
|
||||
/// would run them as one.
|
||||
inboxes: Mutex<HashMap<i64, Arc<ConversationInbox>>>,
|
||||
/// Weak self-reference, set in `new()`, so lazily-spawned consumers can
|
||||
/// reach back into the hub to dispatch turns.
|
||||
me: OnceLock<Weak<Self>>,
|
||||
/// Shutdown token, used to stop lazily-spawned source consumers.
|
||||
/// Shutdown token, used to stop lazily-spawned consumers.
|
||||
shutdown: CancellationToken,
|
||||
/// Per-source pinned LLM client (e.g. set via `/model` or the web dropdown).
|
||||
/// Keyed by source id; value is a `client_names()` entry (`"auto"` or a
|
||||
/// model name). When absent the caller AUTO-resolves. In-memory only: a
|
||||
/// server restart clears all pins (intentional for the MVP).
|
||||
selected_clients: Mutex<HashMap<String, String>>,
|
||||
/// Per-conversation pinned LLM client (e.g. set via `/model` or the web
|
||||
/// dropdown). Keyed by session id; value is a `client_names()` entry
|
||||
/// (`"auto"` or a model name). When absent the caller AUTO-resolves.
|
||||
/// In-memory only: a server restart clears all pins (intentional for the MVP).
|
||||
///
|
||||
/// Per conversation rather than per source for the same reason as `inboxes`,
|
||||
/// and because it is what the persisted security group already does — two tabs
|
||||
/// on one source must not share a model pin.
|
||||
selected_clients: Mutex<HashMap<i64, String>>,
|
||||
/// The entry agent used when a source has no session yet and the caller did
|
||||
/// not specify one. Resolved once, at login, from the owner's role
|
||||
/// (`attrs.chat_agent`, else `DEFAULT_CHAT_AGENT`) — this hub is owner-bound,
|
||||
@@ -176,7 +187,22 @@ impl ChatHub {
|
||||
prompt: &str,
|
||||
opts: SendMessageOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
let inbox = self.get_or_spawn_inbox(source_id).await;
|
||||
let agent_id = opts.agent_id.clone().unwrap_or_else(|| self.default_agent.clone());
|
||||
let session_id = self.get_or_create_session(source_id, &agent_id).await?;
|
||||
self.send_message_to_session(session_id, prompt, opts).await
|
||||
}
|
||||
|
||||
/// Enqueue a user message for one specific conversation, whether or not it is
|
||||
/// the one its source currently points at. This is what the copilot's extra
|
||||
/// tabs talk to; [`Self::send_message`] is the same thing after resolving a
|
||||
/// source to its active session.
|
||||
pub async fn send_message_to_session(
|
||||
&self,
|
||||
session_id: i64,
|
||||
prompt: &str,
|
||||
opts: SendMessageOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
let inbox = self.get_or_spawn_inbox(session_id).await?;
|
||||
inbox.pending.lock().await.push_back(QueuedMessage {
|
||||
prompt: prompt.to_string(),
|
||||
opts,
|
||||
@@ -185,23 +211,36 @@ impl ChatHub {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the source's inbox, creating it (and spawning its consumer) on first use.
|
||||
async fn get_or_spawn_inbox(&self, source_id: &str) -> Arc<SourceInbox> {
|
||||
/// Returns the conversation's inbox, creating it (and spawning its consumer)
|
||||
/// on first use. The source is resolved once here, from the session's own row,
|
||||
/// because the consumer needs it to tag events for connected clients.
|
||||
async fn get_or_spawn_inbox(&self, session_id: i64) -> anyhow::Result<Arc<ConversationInbox>> {
|
||||
let mut inboxes = self.inboxes.lock().await;
|
||||
if let Some(inbox) = inboxes.get(source_id) {
|
||||
return Arc::clone(inbox);
|
||||
if let Some(inbox) = inboxes.get(&session_id) {
|
||||
return Ok(Arc::clone(inbox));
|
||||
}
|
||||
let inbox = Arc::new(SourceInbox::default());
|
||||
inboxes.insert(source_id.to_string(), Arc::clone(&inbox));
|
||||
let source = self.source_of(session_id).await;
|
||||
let inbox = Arc::new(ConversationInbox::default());
|
||||
inboxes.insert(session_id, Arc::clone(&inbox));
|
||||
let weak = self.me.get().expect("ChatHub::me must be set in new()").clone();
|
||||
tokio::spawn(Self::source_consumer(
|
||||
tokio::spawn(Self::conversation_consumer(
|
||||
weak,
|
||||
source_id.to_string(),
|
||||
session_id,
|
||||
source.clone(),
|
||||
Arc::clone(&inbox),
|
||||
self.shutdown.clone(),
|
||||
));
|
||||
info!(source_id, "ChatHub: source inbox + consumer spawned");
|
||||
inbox
|
||||
info!(session_id, source, "ChatHub: conversation inbox + consumer spawned");
|
||||
Ok(inbox)
|
||||
}
|
||||
|
||||
/// The source a session answers on. Sessions carry it on their own row, so this
|
||||
/// never depends on where a source currently points.
|
||||
async fn source_of(&self, session_id: i64) -> String {
|
||||
match chat_sessions::find_by_id(&self.db, session_id).await {
|
||||
Ok(Some(s)) => s.source,
|
||||
_ => DEFAULT_HOME_SOURCE.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one LLM turn for a coalesced unit: resolves session/handler, bridges
|
||||
@@ -209,16 +248,15 @@ impl ChatHub {
|
||||
/// (which takes the per-session `processing` lock).
|
||||
async fn dispatch_turn(
|
||||
&self,
|
||||
source_id: &str,
|
||||
prompt: &str,
|
||||
opts: SendMessageOptions,
|
||||
// Live user-input source for this turn (the source's inbox). The running
|
||||
// turn drains it at each round boundary to inject messages queued while it
|
||||
// was busy. `None` for synthetic turns, which never inject.
|
||||
session_id: i64,
|
||||
source_id: &str,
|
||||
prompt: &str,
|
||||
opts: SendMessageOptions,
|
||||
// Live user-input source for this turn (the conversation's inbox). The
|
||||
// running turn drains it at each round boundary to inject messages queued
|
||||
// while it was busy. `None` for synthetic turns, which never inject.
|
||||
pending_input: Option<Arc<dyn PendingUserInput>>,
|
||||
) -> anyhow::Result<()> {
|
||||
let agent_id = opts.agent_id.as_deref().unwrap_or(&self.default_agent);
|
||||
let session_id = self.get_or_create_session(source_id, agent_id).await?;
|
||||
let source_tag = source_id.to_string();
|
||||
|
||||
// Bridge mpsc from handle_message → global broadcast, tagging with source/session.
|
||||
@@ -272,6 +310,29 @@ impl ChatHub {
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<Attachment> {
|
||||
let handler = self.session_handler(source_id).await?;
|
||||
self.save_upload_with(handler, file_name, client_mime, bytes).await
|
||||
}
|
||||
|
||||
/// [`Self::save_upload`] for one specific conversation, so an extra tab's
|
||||
/// attachment lands in the directory that tab's next message references.
|
||||
pub async fn save_upload_to_session(
|
||||
&self,
|
||||
session_id: i64,
|
||||
file_name: &str,
|
||||
client_mime: Option<String>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<Attachment> {
|
||||
let handler = self.handler_for_session(session_id).await?;
|
||||
self.save_upload_with(handler, file_name, client_mime, bytes).await
|
||||
}
|
||||
|
||||
async fn save_upload_with(
|
||||
&self,
|
||||
handler: Arc<ChatSessionHandler>,
|
||||
file_name: &str,
|
||||
client_mime: Option<String>,
|
||||
bytes: &[u8],
|
||||
) -> anyhow::Result<Attachment> {
|
||||
let fs = handler.user_fs();
|
||||
let att = crate::uploads::save_to_home(
|
||||
&fs,
|
||||
@@ -309,13 +370,13 @@ impl ChatHub {
|
||||
reset: bool,
|
||||
) -> anyhow::Result<i64> {
|
||||
// A reset discards the current session; drop any messages queued for it.
|
||||
let current = sources::active_session_id(&self.db, source_id).await?;
|
||||
if reset {
|
||||
self.clear_inbox(source_id).await;
|
||||
}
|
||||
if !reset {
|
||||
if let Some(sid) = sources::active_session_id(&self.db, source_id).await? {
|
||||
return Ok(sid);
|
||||
if let Some(sid) = current {
|
||||
self.retire_inbox(sid).await;
|
||||
}
|
||||
} else if let Some(sid) = current {
|
||||
return Ok(sid);
|
||||
}
|
||||
let (session_id, _) = self.session_mgr
|
||||
.create_session(agent_id, source_id, true, false, run_context)
|
||||
@@ -332,6 +393,28 @@ impl ChatHub {
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Create an **additional** conversation on a source, leaving the source's
|
||||
/// pointer where it is.
|
||||
///
|
||||
/// This is the difference between a second tab and a reset: `sources
|
||||
/// .active_session_id` keeps naming the conversation that background delivery
|
||||
/// reaches (`notify`, `/sethome`, an inbound channel message), and the new one
|
||||
/// is reachable only by its id. Its agent and run-context come from the source
|
||||
/// like any other, so an extra tab on a project is still the coordinator with
|
||||
/// the project's context.
|
||||
pub async fn create_additional_session(
|
||||
&self,
|
||||
source_id: &str,
|
||||
agent_id: &str,
|
||||
run_context: Option<&crate::run_context::RunContext>,
|
||||
) -> anyhow::Result<i64> {
|
||||
let (session_id, _) = self.session_mgr
|
||||
.create_session(agent_id, source_id, true, false, run_context)
|
||||
.await?;
|
||||
info!(source_id, session_id, agent_id, "ChatHub: additional session created");
|
||||
Ok(session_id)
|
||||
}
|
||||
|
||||
/// Create a new session for the source, discarding the previous one.
|
||||
/// Thin wrapper over `provision_session` using the owner's default entry agent
|
||||
/// (kept for the `ChatHubApi` trait and generic callers).
|
||||
@@ -369,6 +452,11 @@ impl ChatHub {
|
||||
/// messages exist or the provider did not report usage.
|
||||
pub async fn context_info(&self, source_id: &str) -> anyhow::Result<(Option<i64>, Option<i64>)> {
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
self.context_info_for_session(session_id).await
|
||||
}
|
||||
|
||||
/// [`Self::context_info`] for one specific conversation.
|
||||
pub async fn context_info_for_session(&self, session_id: i64) -> anyhow::Result<(Option<i64>, Option<i64>)> {
|
||||
let stack = match chat_sessions_stack::active_for_session(&self.db, session_id).await? {
|
||||
Some(s) => s,
|
||||
None => return Ok((None, None)),
|
||||
@@ -382,6 +470,11 @@ impl ChatHub {
|
||||
/// session). `None` when no provider reported a cost.
|
||||
pub async fn cost_info(&self, source_id: &str) -> anyhow::Result<Option<f64>> {
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
self.cost_info_for_session(session_id).await
|
||||
}
|
||||
|
||||
/// [`Self::cost_info`] for one specific conversation.
|
||||
pub async fn cost_info_for_session(&self, session_id: i64) -> anyhow::Result<Option<f64>> {
|
||||
chat_history::total_cost_for_session(&self.db, session_id).await
|
||||
}
|
||||
|
||||
@@ -392,6 +485,12 @@ impl ChatHub {
|
||||
handler.force_compact().await
|
||||
}
|
||||
|
||||
/// [`Self::force_compact`] for one specific conversation.
|
||||
pub async fn force_compact_for_session(&self, session_id: i64) -> anyhow::Result<bool> {
|
||||
let handler = self.handler_for_session(session_id).await?;
|
||||
handler.force_compact().await
|
||||
}
|
||||
|
||||
/// Resume any interrupted turn for a source's active session.
|
||||
/// Calls `recover_turn`, which re-executes pending tool calls (approval or
|
||||
/// clarification) and re-runs the LLM loop if needed.
|
||||
@@ -419,6 +518,20 @@ impl ChatHub {
|
||||
self.resume_session(session_id).await
|
||||
}
|
||||
|
||||
/// [`Self::resume`] for one specific conversation, guard included: a client
|
||||
/// sends `resume` on connect whenever history shows a pending tool, which is
|
||||
/// also true while the original turn is merely waiting on an approval. Running
|
||||
/// a second turn on top of that is the bug this check exists to prevent.
|
||||
pub async fn resume_for_session(&self, session_id: i64) -> anyhow::Result<()> {
|
||||
if let Ok(handler) = self.handler_for_session(session_id).await {
|
||||
if handler.is_processing() {
|
||||
info!(session_id, "ChatHub::resume_for_session: turn already in flight — skipping");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
self.resume_session(session_id).await
|
||||
}
|
||||
|
||||
/// Resume an interrupted turn for a specific `session_id` (post-restart recovery
|
||||
/// or after a manual approval resolve), independent of any source's active session.
|
||||
/// Injects `execute_task` so a pending sub-agent task can be re-dispatched, and
|
||||
@@ -515,8 +628,13 @@ impl ChatHub {
|
||||
/// The next LLM turn will start with no MCP servers activated.
|
||||
pub async fn reset_mcp(&self, source_id: &str) -> anyhow::Result<()> {
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await?;
|
||||
self.reset_mcp_for_session(session_id).await
|
||||
}
|
||||
|
||||
/// [`Self::reset_mcp`] for one specific conversation.
|
||||
pub async fn reset_mcp_for_session(&self, session_id: i64) -> anyhow::Result<()> {
|
||||
crate::db::activated_tools::revoke_all_session(&self.db, session_id).await?;
|
||||
info!(source_id, session_id, "ChatHub: MCP grants reset");
|
||||
info!(session_id, "ChatHub: MCP grants reset");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -537,44 +655,80 @@ impl ChatHub {
|
||||
(mgr.client_names().await, mgr.default_name().await)
|
||||
}
|
||||
|
||||
/// Returns the client name pinned for the source, or `None` when unset
|
||||
/// (the caller should fall back to AUTO resolution).
|
||||
/// Returns the client name pinned for the source's active conversation, or
|
||||
/// `None` when unset (the caller should fall back to AUTO resolution).
|
||||
pub async fn get_selected_client(&self, source_id: &str) -> Option<String> {
|
||||
self.selected_clients.lock().await.get(source_id).cloned()
|
||||
let session_id = self.get_or_create_session(source_id, &self.default_agent).await.ok()?;
|
||||
self.get_selected_client_for_session(session_id).await
|
||||
}
|
||||
|
||||
/// Pin a client name for the source and broadcast `ClientSelected`.
|
||||
/// `client` should be a `list_clients()` entry (`"auto"` or a model name).
|
||||
/// [`Self::get_selected_client`] for one specific conversation.
|
||||
pub async fn get_selected_client_for_session(&self, session_id: i64) -> Option<String> {
|
||||
self.selected_clients.lock().await.get(&session_id).cloned()
|
||||
}
|
||||
|
||||
/// Pin a client name for the source's active conversation and broadcast
|
||||
/// `ClientSelected`. `client` should be a `list_clients()` entry.
|
||||
pub async fn set_selected_client(&self, source_id: &str, client: String) {
|
||||
info!(source_id, client = %client, "ChatHub: selected client set");
|
||||
self.selected_clients.lock().await.insert(source_id.to_string(), client.clone());
|
||||
match self.get_or_create_session(source_id, &self.default_agent).await {
|
||||
Ok(session_id) => self.set_selected_client_for_session(session_id, client).await,
|
||||
Err(e) => warn!(source_id, error = %e, "ChatHub: no session to pin a client on"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::set_selected_client`] for one specific conversation. The broadcast
|
||||
/// carries the session id so only the tab that owns this conversation reacts —
|
||||
/// two tabs on one source have two independent pins.
|
||||
pub async fn set_selected_client_for_session(&self, session_id: i64, client: String) {
|
||||
info!(session_id, client = %client, "ChatHub: selected client set");
|
||||
self.selected_clients.lock().await.insert(session_id, client.clone());
|
||||
let source = self.source_of(session_id).await;
|
||||
self.emit(GlobalEvent {
|
||||
source: Some(source_id.to_string()),
|
||||
session_id: None,
|
||||
source: Some(source),
|
||||
session_id: Some(session_id),
|
||||
event: ServerEvent::ClientSelected { client },
|
||||
});
|
||||
}
|
||||
|
||||
/// Clear any pinned client for the source (revert to AUTO) and broadcast
|
||||
/// `ClientSelected { client: "auto" }`.
|
||||
/// Clear any pinned client for the source's active conversation (revert to
|
||||
/// AUTO) and broadcast `ClientSelected { client: "auto" }`.
|
||||
pub async fn clear_selected_client(&self, source_id: &str) {
|
||||
info!(source_id, "ChatHub: selected client cleared (auto)");
|
||||
self.selected_clients.lock().await.remove(source_id);
|
||||
match self.get_or_create_session(source_id, &self.default_agent).await {
|
||||
Ok(session_id) => self.clear_selected_client_for_session(session_id).await,
|
||||
Err(e) => warn!(source_id, error = %e, "ChatHub: no session to clear a pin on"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::clear_selected_client`] for one specific conversation.
|
||||
pub async fn clear_selected_client_for_session(&self, session_id: i64) {
|
||||
info!(session_id, "ChatHub: selected client cleared (auto)");
|
||||
self.selected_clients.lock().await.remove(&session_id);
|
||||
let source = self.source_of(session_id).await;
|
||||
self.emit(GlobalEvent {
|
||||
source: Some(source_id.to_string()),
|
||||
session_id: None,
|
||||
source: Some(source),
|
||||
session_id: Some(session_id),
|
||||
event: ServerEvent::ClientSelected { client: "auto".to_string() },
|
||||
});
|
||||
}
|
||||
|
||||
/// Snapshot of the model list with the per-source current selection marked.
|
||||
/// Snapshot of the model list with the conversation's current selection marked.
|
||||
/// Returns `(index, name, is_current)` tuples so call sites can render
|
||||
/// HTML (Telegram) or Markdown (web) without re-querying the LLM manager
|
||||
/// or the pin store.
|
||||
pub async fn list_clients_marked(&self, source_id: &str) -> Vec<(usize, String, bool)> {
|
||||
let current = self.get_selected_client(source_id).await;
|
||||
self.mark_clients(current).await
|
||||
}
|
||||
|
||||
/// [`Self::list_clients_marked`] for one specific conversation.
|
||||
pub async fn list_clients_marked_for_session(&self, session_id: i64) -> Vec<(usize, String, bool)> {
|
||||
let current = self.get_selected_client_for_session(session_id).await;
|
||||
self.mark_clients(current).await
|
||||
}
|
||||
|
||||
async fn mark_clients(&self, current: Option<String>) -> Vec<(usize, String, bool)> {
|
||||
let (models, _default) = self.list_clients().await;
|
||||
let current = self.get_selected_client(source_id).await
|
||||
.unwrap_or_else(|| "auto".to_string());
|
||||
let current = current.unwrap_or_else(|| "auto".to_string());
|
||||
models.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, name)| (i, name.clone(), name == current))
|
||||
@@ -589,16 +743,28 @@ impl ChatHub {
|
||||
&self,
|
||||
source_id: &str,
|
||||
arg: &str,
|
||||
) -> ModelCommandOutcome {
|
||||
match self.get_or_create_session(source_id, &self.default_agent).await {
|
||||
Ok(session_id) => self.apply_model_command_for_session(session_id, arg).await,
|
||||
Err(e) => ModelCommandOutcome::Error(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::apply_model_command`] for one specific conversation.
|
||||
pub async fn apply_model_command_for_session(
|
||||
&self,
|
||||
session_id: i64,
|
||||
arg: &str,
|
||||
) -> ModelCommandOutcome {
|
||||
let (models, _default) = self.list_clients().await;
|
||||
match core_api::chat_hub::resolve_list_arg(&models, arg) {
|
||||
Ok(Some(client)) => {
|
||||
let name = client.clone();
|
||||
self.set_selected_client(source_id, client).await;
|
||||
self.set_selected_client_for_session(session_id, client).await;
|
||||
ModelCommandOutcome::Set(name)
|
||||
}
|
||||
Ok(None) => {
|
||||
self.clear_selected_client(source_id).await;
|
||||
self.clear_selected_client_for_session(session_id).await;
|
||||
ModelCommandOutcome::Cleared
|
||||
}
|
||||
Err(msg) => ModelCommandOutcome::Error(msg),
|
||||
@@ -608,22 +774,39 @@ impl ChatHub {
|
||||
/// Cancel the active LLM turn for the source's session, clearing any pending
|
||||
/// approvals and clarification questions. No-op if no session is active.
|
||||
pub async fn cancel(&self, source_id: &str) {
|
||||
match self.get_or_create_session(source_id, &self.default_agent).await {
|
||||
Ok(session_id) => self.cancel_session(session_id).await,
|
||||
Err(e) => warn!(source_id, error = %e, "ChatHub::cancel: no session to cancel"),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::cancel`] for one specific conversation.
|
||||
pub async fn cancel_session(&self, session_id: i64) {
|
||||
// Drop queued-but-not-yet-dispatched messages so /stop clears the backlog
|
||||
// too, not just the in-flight turn.
|
||||
self.clear_inbox(source_id).await;
|
||||
match self.session_handler(source_id).await {
|
||||
self.clear_inbox(session_id).await;
|
||||
match self.handler_for_session(session_id).await {
|
||||
Ok(handler) => {
|
||||
handler.cancel();
|
||||
handler.cancel_pending_approvals().await;
|
||||
handler.cancel_pending_questions().await;
|
||||
info!(source_id, "ChatHub: cancel requested");
|
||||
info!(session_id, "ChatHub: cancel requested");
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(source_id, error = %e, "ChatHub::cancel: no session to cancel");
|
||||
warn!(session_id, error = %e, "ChatHub::cancel: no session to cancel");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answer a clarification question raised by one specific conversation.
|
||||
pub async fn resolve_question_for_session(&self, session_id: i64, request_id: i64, answer: String) {
|
||||
match self.handler_for_session(session_id).await {
|
||||
Ok(handler) => handler.resolve_question(request_id, answer).await,
|
||||
Err(e) => warn!(session_id, request_id, error = %e,
|
||||
"ChatHub::resolve_question_for_session: no session handler"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Approve a pending tool-call approval request.
|
||||
pub async fn approve(&self, request_id: i64) {
|
||||
self.approval.approve(request_id).await;
|
||||
@@ -673,18 +856,20 @@ impl ChatHub {
|
||||
|
||||
/// Per-source consumer: drains and coalesces queued messages, running one turn
|
||||
/// at a time. Spawned lazily by `get_or_spawn_inbox`; lives until shutdown.
|
||||
async fn source_consumer(
|
||||
hub: Weak<Self>,
|
||||
source_id: String,
|
||||
inbox: Arc<SourceInbox>,
|
||||
shutdown: CancellationToken,
|
||||
async fn conversation_consumer(
|
||||
hub: Weak<Self>,
|
||||
session_id: i64,
|
||||
source_id: String,
|
||||
inbox: Arc<ConversationInbox>,
|
||||
shutdown: CancellationToken,
|
||||
) {
|
||||
info!(%source_id, "ChatHub: source consumer started");
|
||||
info!(session_id, %source_id, "ChatHub: conversation consumer started");
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = shutdown.cancelled() => break,
|
||||
_ = inbox.notify.notified() => {}
|
||||
}
|
||||
if inbox.is_closed() { break }
|
||||
|
||||
// Optional idle-batching window (0 = disabled).
|
||||
if SOURCE_COALESCE_DEBOUNCE_MS > 0 {
|
||||
@@ -723,23 +908,33 @@ impl ChatHub {
|
||||
let hub_turn = Arc::clone(&hub);
|
||||
let src = source_id.clone();
|
||||
let turn = tokio::spawn(async move {
|
||||
hub_turn.dispatch_turn(&src, &prompt, opts, pending_input).await
|
||||
hub_turn.dispatch_turn(session_id, &src, &prompt, opts, pending_input).await
|
||||
});
|
||||
match turn.await {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => error!(%source_id, error = %e, "ChatHub: source turn failed"),
|
||||
Err(e) => error!(%source_id, error = %e, "ChatHub: source turn panicked — consumer surviving"),
|
||||
Ok(Err(e)) => error!(session_id, error = %e, "ChatHub: turn failed"),
|
||||
Err(e) => error!(session_id, error = %e, "ChatHub: turn panicked — consumer surviving"),
|
||||
}
|
||||
}
|
||||
}
|
||||
info!(%source_id, "ChatHub: source consumer stopped");
|
||||
info!(session_id, %source_id, "ChatHub: conversation consumer stopped");
|
||||
}
|
||||
|
||||
/// Clears a source's pending queue and bumps its cancel epoch (so a unit the
|
||||
/// consumer drained just before a `/stop` is dropped instead of dispatched).
|
||||
/// No-op if the source has no inbox yet.
|
||||
async fn clear_inbox(&self, source_id: &str) {
|
||||
if let Some(inbox) = self.inboxes.lock().await.get(source_id) {
|
||||
/// Clears a conversation's pending queue and bumps its cancel epoch (so a unit
|
||||
/// the consumer drained just before a `/stop` is dropped instead of dispatched).
|
||||
/// No-op if the conversation has no inbox yet.
|
||||
/// Drops a conversation's queue for good — used when a reset replaces it, so
|
||||
/// neither the queue nor its consumer task outlives what it served.
|
||||
async fn retire_inbox(&self, session_id: i64) {
|
||||
if let Some(inbox) = self.inboxes.lock().await.remove(&session_id) {
|
||||
inbox.pending.lock().await.clear();
|
||||
inbox.cancel_epoch.fetch_add(1, Ordering::Release);
|
||||
inbox.close();
|
||||
}
|
||||
}
|
||||
|
||||
async fn clear_inbox(&self, session_id: i64) {
|
||||
if let Some(inbox) = self.inboxes.lock().await.get(&session_id) {
|
||||
inbox.pending.lock().await.clear();
|
||||
inbox.cancel_epoch.fetch_add(1, Ordering::Release);
|
||||
}
|
||||
@@ -839,9 +1034,9 @@ impl ChatHub {
|
||||
|
||||
// ── Live user-input source ──────────────────────────────────────────────────
|
||||
|
||||
/// Adapts a source's `SourceInbox` to the handler's `PendingUserInput` trait so a
|
||||
/// Adapts a conversation's `ConversationInbox` to the handler's `PendingUserInput` trait so a
|
||||
/// running turn can drain newly-queued user messages at its round boundaries.
|
||||
struct InboxUserInput(Arc<SourceInbox>);
|
||||
struct InboxUserInput(Arc<ConversationInbox>);
|
||||
|
||||
#[async_trait]
|
||||
impl PendingUserInput for InboxUserInput {
|
||||
|
||||
@@ -79,6 +79,18 @@ pub async fn set_open(pool: &SqlitePool, id: i64, open: bool) -> anyhow::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rename a conversation. An empty title is stored as `NULL`, so clearing the
|
||||
/// box gives back the automatic label rather than a blank tab.
|
||||
pub async fn set_title(pool: &SqlitePool, id: i64, title: Option<&str>) -> anyhow::Result<()> {
|
||||
let title = title.map(str::trim).filter(|t| !t.is_empty());
|
||||
sqlx::query("UPDATE chat_sessions SET title = ? WHERE id = ?")
|
||||
.bind(title)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The tabs to restore, in creation order so the bar keeps a stable layout.
|
||||
pub async fn list_open(pool: &SqlitePool) -> anyhow::Result<Vec<OpenSession>> {
|
||||
let rows = sqlx::query_as::<_, (i64, String, Option<String>)>(
|
||||
@@ -146,4 +158,39 @@ mod tests {
|
||||
assert!(find_by_id(&pool, b.id).await.unwrap().is_some());
|
||||
assert!(find_by_id(&pool, a.id).await.unwrap().is_some());
|
||||
}
|
||||
|
||||
/// One source, two open conversations — the shape the copilot's `+` produces
|
||||
/// and the one the old per-source model could not express. Order is by id, so
|
||||
/// the bar lays out the same way on every device.
|
||||
#[tokio::test]
|
||||
async fn a_source_can_hold_several_open_conversations() {
|
||||
let pool = owner_pool().await;
|
||||
let mut ids = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let s = create(&pool, "assistant", "web", true, false).await.unwrap();
|
||||
set_open(&pool, s.id, true).await.unwrap();
|
||||
ids.push(s.id);
|
||||
}
|
||||
let open = list_open(&pool).await.unwrap();
|
||||
assert_eq!(open.iter().map(|s| s.id).collect::<Vec<_>>(), ids);
|
||||
}
|
||||
|
||||
/// Clearing the name gives back the automatic label instead of a blank tab, so
|
||||
/// the rename box is also how a rename is undone. Whitespace counts as empty.
|
||||
#[tokio::test]
|
||||
async fn an_empty_title_clears_the_name() {
|
||||
let pool = owner_pool().await;
|
||||
let s = create(&pool, "assistant", "web", true, false).await.unwrap();
|
||||
set_open(&pool, s.id, true).await.unwrap();
|
||||
|
||||
set_title(&pool, s.id, Some(" Trip planning ")).await.unwrap();
|
||||
assert_eq!(list_open(&pool).await.unwrap()[0].title.as_deref(), Some("Trip planning"));
|
||||
|
||||
set_title(&pool, s.id, Some(" ")).await.unwrap();
|
||||
assert!(list_open(&pool).await.unwrap()[0].title.is_none());
|
||||
|
||||
set_title(&pool, s.id, Some("Named again")).await.unwrap();
|
||||
set_title(&pool, s.id, None).await.unwrap();
|
||||
assert!(list_open(&pool).await.unwrap()[0].title.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +255,7 @@ impl ChatSessionManager {
|
||||
///
|
||||
/// Returns `(source, effective group)` for each session that actually changed —
|
||||
/// the caller broadcasts `SecurityGroupSelected` so open tabs re-sync their pill.
|
||||
pub async fn revalidate_security_groups(&self) -> Vec<(String, String)> {
|
||||
pub async fn revalidate_security_groups(&self) -> Vec<(i64, String, String)> {
|
||||
let handlers: Vec<_> = self.active.lock().await
|
||||
.iter().map(|(id, h)| (*id, Arc::clone(h))).collect();
|
||||
|
||||
@@ -281,6 +281,7 @@ impl ChatSessionManager {
|
||||
}
|
||||
handler.set_run_context(after).await;
|
||||
changed.push((
|
||||
session_id,
|
||||
handler.source.clone(),
|
||||
after_group.unwrap_or_else(|| crate::run_context::DEFAULT_GROUP_ID.to_string()),
|
||||
));
|
||||
|
||||
@@ -119,10 +119,12 @@ impl Skald {
|
||||
/// reconcile anyway.
|
||||
pub async fn revalidate_security_groups_for_user(&self, user_id: &str) {
|
||||
let Some(ctx) = self.user_context_if_live(user_id).await else { return };
|
||||
for (source, group) in ctx.sessions.revalidate_security_groups().await {
|
||||
for (session_id, source, group) in ctx.sessions.revalidate_security_groups().await {
|
||||
ctx.chat_hub.emit(core_api::events::GlobalEvent {
|
||||
source: Some(source),
|
||||
session_id: None,
|
||||
// Tagged with the conversation: clients filter per conversation, so
|
||||
// an untagged degrade would leave every pill showing the old group.
|
||||
session_id: Some(session_id),
|
||||
event: core_api::events::ServerEvent::SecurityGroupSelected { group },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,9 +29,13 @@ use crate::tools::tool_names::SHOW_FILE_TO_USER;
|
||||
/// the file-viewer page fetches the same file back through `/api/file`. The
|
||||
/// frontend renders every kind in the viewer (HTML live in an origin-isolated
|
||||
/// iframe; LaTeX compiled to PDF server-side).
|
||||
///
|
||||
/// `session_id` is the conversation this instance belongs to: clients filter
|
||||
/// events per conversation, so an untagged `OpenFile` would reach nobody.
|
||||
pub fn make_tool(
|
||||
hub: Arc<ChatHub>,
|
||||
source: String,
|
||||
session_id: i64,
|
||||
fs: SharedFs,
|
||||
user_pool: SqlitePool,
|
||||
shared_pool: SqlitePool,
|
||||
@@ -103,7 +107,7 @@ pub fn make_tool(
|
||||
let display = format!("{root}/{}", mem.rel);
|
||||
hub.emit(GlobalEvent {
|
||||
source: Some(source),
|
||||
session_id: None,
|
||||
session_id: Some(session_id),
|
||||
event: ServerEvent::OpenFile { path: display.clone() },
|
||||
});
|
||||
return Ok(format!("Opened {display} in the user's viewer."));
|
||||
@@ -132,7 +136,7 @@ pub fn make_tool(
|
||||
|
||||
hub.emit(GlobalEvent {
|
||||
source: Some(source),
|
||||
session_id: None,
|
||||
session_id: Some(session_id),
|
||||
event: ServerEvent::OpenFile { path: display.clone() },
|
||||
});
|
||||
Ok(format!("Opened {display} in the user's viewer."))
|
||||
|
||||
+9
-1
@@ -20,7 +20,15 @@ Opening a project shows its page, with two tabs (the current tab is part of the
|
||||
|
||||
The header also has an **Open chat** button: it opens the project's conversation with the assistant. The assistant already knows the project folder and works directly inside it — creating documents, searching, summarizing. Each member has their **own private** conversation about the project; only the files are shared.
|
||||
|
||||
The conversation opens as a **tab** in the chat panel, next to the General one. Those tabs stay open: they survive a page reload, and because they are saved to your account rather than to the browser, you find the same ones when you sign in from another device. Closing a tab only removes it from the bar — the conversation itself is kept, and reopening the project brings it back with its history. The General tab is always there and cannot be closed.
|
||||
The conversation opens as a **tab** in the chat panel, next to the General one. **Open chat** always takes you back to the project's own conversation, with everything you had already said in it — it never starts a fresh one.
|
||||
|
||||
Those tabs stay open: they survive a page reload, and because they are saved to your account rather than to the browser, you find the same ones when you sign in from another device. Closing a tab only removes it from the bar — the conversation itself is kept, and reopening the project brings it back with its history. The General tab is always there and cannot be closed.
|
||||
|
||||
**Working on two things at once.** The **+** button at the end of the tab bar opens one more chat, either general or on a project you belong to. It is a separate conversation with its own history: the assistant in it knows nothing about what you are saying in the other tabs, which is the point — you can leave a long piece of work open in one tab and ask something unrelated in another without mixing them up. On a project, the extra chat knows the project's folder and members just like the main one.
|
||||
|
||||
Two differences between a project's own chat and an extra one are worth knowing. Notifications from the assistant, results of background tasks and messages arriving from a connected chat app are delivered to the project's own conversation (and General for everything else) — never to an extra tab. And **Open chat** always lands on the project's own conversation, so an extra chat is reached only from its tab.
|
||||
|
||||
**Renaming.** Double-click a tab to give it a name, then press Enter. Clearing the box restores the automatic name.
|
||||
|
||||
## The Files tab
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+244
-67
@@ -31,6 +31,27 @@ const GENERAL_SOURCE = 'web';
|
||||
// same browser never sees it.
|
||||
const ACTIVE_TAB_KEY = 'copilot-active-tab';
|
||||
|
||||
// ── The two kinds of tab ──────────────────────────────────────────────────────
|
||||
//
|
||||
// A **primary** tab is a source: it shows whatever `web` or `project-7` currently
|
||||
// points at, which is also where background delivery lands (a notification, a
|
||||
// finished task, an inbound Telegram message) and what a `/new` moves to a fresh
|
||||
// conversation. There is at most one per source, and every project's "Open chat"
|
||||
// lands on it.
|
||||
//
|
||||
// A **secondary** tab is one specific conversation, opened with `+`. Its source
|
||||
// points elsewhere, so it is unreachable by source name and is addressed by id
|
||||
// throughout — REST, WebSocket and event filtering alike. Nothing is delivered to
|
||||
// it from the outside; it is a place to work on a second thing at once.
|
||||
//
|
||||
// The key is what the selection is stored under and what the render loop tracks,
|
||||
// so it must stay stable while a tab lives. A primary tab keeps its key across a
|
||||
// reset (the source is the identity); a secondary tab's key is its session.
|
||||
const primaryTab = (source, label, sessionId = null) =>
|
||||
({ key: `src:${source}`, source, sessionId, label, title: null, primary: true });
|
||||
const secondaryTab = (source, sessionId, label, title = null) =>
|
||||
({ key: `ses:${sessionId}`, source, sessionId, label, title, primary: false });
|
||||
|
||||
export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
static properties = {
|
||||
_collapsed: { state: true },
|
||||
@@ -40,6 +61,10 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
_groupOpen: { state: true },
|
||||
_tabs: { state: true },
|
||||
_activeSource: { state: true },
|
||||
_activeSessionId: { state: true },
|
||||
_newTabOpen: { state: true },
|
||||
_newTabTargets: { state: true },
|
||||
_renamingKey: { state: true },
|
||||
_cmdMenu: { state: true },
|
||||
_cmdSel: { state: true },
|
||||
};
|
||||
@@ -59,11 +84,15 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
this._cmdMenu = null;
|
||||
this._cmdSel = 0;
|
||||
this._allCommands = null;
|
||||
// Browser-style tabs: 'General' (the default 'web' source) is always present and
|
||||
// not closable; project chats are added on demand and addressed by their source.
|
||||
// Each carries the id of the session it shows, which is what `is_open` hangs on
|
||||
// server-side — and what a `/new` reset moves to a different row.
|
||||
this._tabs = [{ source: GENERAL_SOURCE, label: t('chat.tab.general') }];
|
||||
// Browser-style tabs. Two kinds, and the difference is which conversation they
|
||||
// name — see `TAB` below. 'General' is always present and not closable.
|
||||
this._tabs = [primaryTab(GENERAL_SOURCE, t('chat.tab.general'))];
|
||||
// The `+` menu: null when closed, otherwise the list of things a new chat can
|
||||
// be started on (General + the caller's projects), fetched on first open.
|
||||
this._newTabOpen = false;
|
||||
this._newTabTargets = null;
|
||||
// Key of the tab being renamed inline, if any.
|
||||
this._renamingKey = null;
|
||||
this._onResizeMove = this._onResizeMove.bind(this);
|
||||
this._onResizeUp = this._onResizeUp.bind(this);
|
||||
this._onKeydown = this._onKeydown.bind(this);
|
||||
@@ -83,8 +112,12 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
// first paint fetches General and then immediately throws it away.
|
||||
// sessionStorage is synchronous, which is what makes this possible; the tab
|
||||
// *set* arrives over the network and reconciles in `_restoreTabs`.
|
||||
const active = sessionStorage.getItem(ACTIVE_TAB_KEY);
|
||||
if (active && active !== GENERAL_SOURCE) this._activeSource = active;
|
||||
const active = sessionStorage.getItem(ACTIVE_TAB_KEY) ?? '';
|
||||
if (active.startsWith('ses:')) {
|
||||
this._activeSessionId = Number(active.slice(4)) || null;
|
||||
} else if (active.startsWith('src:') && active !== `src:${GENERAL_SOURCE}`) {
|
||||
this._activeSource = active.slice(4);
|
||||
}
|
||||
// The base's is async and owns the first WS: hand it to `_restoreTabs`, which
|
||||
// must not switch source while that connection is still being set up.
|
||||
const ready = super.connectedCallback?.();
|
||||
@@ -157,104 +190,247 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
|
||||
// ── Tabs ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// The tab this chat is currently bound to.
|
||||
get _activeKey() {
|
||||
return this._activeSessionId ? `ses:${this._activeSessionId}` : `src:${this._source}`;
|
||||
}
|
||||
|
||||
// Restore the tabs the user left open. They come from their own (encrypted)
|
||||
// database rather than this browser, so the bar is the same on every device and
|
||||
// a shared laptop never mixes two members' tabs.
|
||||
async _restoreTabs(ready) {
|
||||
// A source switch tears down the WS, so it has to wait for the one the base
|
||||
// Switching tabs tears down the WS, so it has to wait for the one the base
|
||||
// opens on mount — otherwise both run and the connection is left doubled.
|
||||
const settled = Promise.resolve(ready).catch(() => {});
|
||||
let tabs = [];
|
||||
let rows = [];
|
||||
try {
|
||||
const res = await fetch('/api/sessions/open');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
tabs = await res.json();
|
||||
rows = await res.json();
|
||||
} catch (e) {
|
||||
console.error('Failed to restore copilot tabs:', e);
|
||||
// The restored selection can't be trusted without the set that justifies it.
|
||||
await settled;
|
||||
if (this._source !== GENERAL_SOURCE) this._selectTab(GENERAL_SOURCE);
|
||||
await this._fallBackToGeneral();
|
||||
return;
|
||||
}
|
||||
// One tab per source, showing its most recent session — the rows arrive in
|
||||
// creation order, so a source left with two open rows resolves to the newer.
|
||||
const bySource = new Map();
|
||||
for (const { source, label, session_id } of tabs) {
|
||||
bySource.set(source, { source, label: label || source, sessionId: session_id });
|
||||
}
|
||||
|
||||
// Merged, not replaced: the user may already have opened a tab while this was
|
||||
// in flight, and it would not be in a response the server built before it.
|
||||
const merged = [...this._tabs];
|
||||
for (const tab of bySource.values()) {
|
||||
const known = merged.find(t => t.source === tab.source);
|
||||
for (const row of rows) {
|
||||
const tab = row.primary
|
||||
? primaryTab(row.source, row.label || row.source, row.session_id)
|
||||
: secondaryTab(row.source, row.session_id, row.label || row.source, row.title);
|
||||
const known = merged.find(t => t.key === tab.key);
|
||||
if (known) { known.sessionId ??= tab.sessionId; continue; }
|
||||
merged.push(tab);
|
||||
}
|
||||
this._tabs = merged;
|
||||
|
||||
// The selection is per window and the set is per user, so they can disagree:
|
||||
// another window may have closed the tab this one had selected.
|
||||
await settled;
|
||||
if (this._source !== GENERAL_SOURCE && !this._tabs.some(t => t.source === this._source)) {
|
||||
this._selectTab(GENERAL_SOURCE);
|
||||
}
|
||||
await this._fallBackToGeneral();
|
||||
}
|
||||
|
||||
// A project chat was opened elsewhere (e.g. the project board): add its tab if
|
||||
// new, expand the copilot, and switch the live connection to it.
|
||||
// Land on General when the bound tab is not (or no longer) in the bar.
|
||||
async _fallBackToGeneral() {
|
||||
if (this._tabs.some(t => t.key === this._activeKey)) return;
|
||||
this._selectTab(`src:${GENERAL_SOURCE}`);
|
||||
}
|
||||
|
||||
// A project chat was opened elsewhere (the board, the sidebar): show its primary
|
||||
// tab, expand the copilot, and switch the live connection to it. Deliberately
|
||||
// never opens a second conversation — "Open chat" resumes the project's own.
|
||||
_onProjectChatOpen(e) {
|
||||
const { source, label, session_id } = e.detail ?? {};
|
||||
if (!source) return;
|
||||
const known = this._tabs.find(t => t.source === source);
|
||||
const key = `src:${source}`;
|
||||
const known = this._tabs.find(t => t.key === key);
|
||||
if (known) {
|
||||
// Keep the id fresh — the session behind a source changes on every reset.
|
||||
this._bindTabSession(known, session_id);
|
||||
} else {
|
||||
this._tabs = [...this._tabs, { source, label: label || source, sessionId: session_id }];
|
||||
this._tabs = [...this._tabs, primaryTab(source, label || source, session_id)];
|
||||
this._persistTab(session_id, true);
|
||||
}
|
||||
this._setCollapsed(false);
|
||||
this._selectTab(source);
|
||||
this._selectTab(key);
|
||||
}
|
||||
|
||||
_selectTab(source) {
|
||||
try { sessionStorage.setItem(ACTIVE_TAB_KEY, source); } catch { /* private mode */ }
|
||||
if (source === this._source) return;
|
||||
this._switchSource(source); // base: tear down WS, reload history, reconnect
|
||||
_selectTab(key) {
|
||||
const tab = this._tabs.find(t => t.key === key);
|
||||
if (!tab) return;
|
||||
try { sessionStorage.setItem(ACTIVE_TAB_KEY, key); } catch { /* private mode */ }
|
||||
if (key === this._activeKey) return;
|
||||
// A primary tab is addressed by source so it keeps following resets; a
|
||||
// secondary one by id, because its source points at a different conversation.
|
||||
this._switchTo(tab.source, tab.primary ? null : tab.sessionId);
|
||||
}
|
||||
|
||||
// Close a project tab. The conversation itself is untouched and can be reopened
|
||||
// from the board — closing only clears its `is_open` flag. The General tab is
|
||||
// never closable and is not a stored tab at all.
|
||||
_closeTab(source, e) {
|
||||
// Close a tab. The conversation itself is untouched — closing only clears its
|
||||
// `is_open` flag, and a project's chat comes back from the board with all its
|
||||
// history. The General tab is never closable and is not a stored tab at all.
|
||||
_closeTab(key, e) {
|
||||
e?.stopPropagation();
|
||||
if (source === GENERAL_SOURCE) return;
|
||||
const tab = this._tabs.find(t => t.source === source);
|
||||
const wasActive = source === this._source;
|
||||
this._tabs = this._tabs.filter(t => t.source !== source);
|
||||
this._persistTab(tab?.sessionId, false);
|
||||
if (wasActive) this._selectTab(GENERAL_SOURCE);
|
||||
if (key === `src:${GENERAL_SOURCE}`) return;
|
||||
const tab = this._tabs.find(t => t.key === key);
|
||||
if (!tab) return;
|
||||
const wasActive = key === this._activeKey;
|
||||
this._tabs = this._tabs.filter(t => t.key !== key);
|
||||
this._persistTab(tab.sessionId, false);
|
||||
if (wasActive) this._selectTab(`src:${GENERAL_SOURCE}`);
|
||||
}
|
||||
|
||||
// A `/new` in a project tab replaced that source's session. `is_open` hangs on
|
||||
// the row, so it has to be carried over or the tab would close itself out from
|
||||
// under the user at the next login.
|
||||
_onSessionReplaced(source, sessionId) {
|
||||
if (source === GENERAL_SOURCE) return;
|
||||
const tab = this._tabs.find(t => t.source === source);
|
||||
if (tab) this._bindTabSession(tab, sessionId);
|
||||
// This chat became a different conversation: a primary tab was reset, or a
|
||||
// secondary one started over. `is_open` hangs on the row, so it has to be moved
|
||||
// or the tab would close itself out from under the user at the next login.
|
||||
_onSessionReplaced(source, sessionId, previous) {
|
||||
const key = previous ? `ses:${previous}` : `src:${source}`;
|
||||
const tab = this._tabs.find(t => t.key === key);
|
||||
if (!tab) return;
|
||||
if (tab.primary) { this._bindTabSession(tab, sessionId); return; }
|
||||
// A secondary tab *is* its session, so starting over replaces the tab.
|
||||
const fresh = secondaryTab(tab.source, sessionId, tab.label, null);
|
||||
this._tabs = this._tabs.map(t => (t.key === key ? fresh : t));
|
||||
this._persistTab(previous, false);
|
||||
this._persistTab(sessionId, true);
|
||||
try { sessionStorage.setItem(ACTIVE_TAB_KEY, fresh.key); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// Point a tab at the session it now shows. The previous one is closed in the
|
||||
// same breath: leaving it open would have the source restore twice, and the
|
||||
// Point a primary tab at the session it now shows. The previous one is closed in
|
||||
// the same breath: leaving it open would have the source restore twice, and the
|
||||
// stale row would be the one a later close cleared.
|
||||
//
|
||||
// General is the exception: it is never a stored tab, so marking its rows open
|
||||
// would leave a trail of flags the bar deliberately ignores and nothing clears.
|
||||
_bindTabSession(tab, sessionId) {
|
||||
if (!sessionId || tab.sessionId === sessionId) return;
|
||||
const previous = tab.sessionId;
|
||||
tab.sessionId = sessionId;
|
||||
if (tab.key === `src:${GENERAL_SOURCE}`) return;
|
||||
if (previous) this._persistTab(previous, false);
|
||||
this._persistTab(sessionId, true);
|
||||
}
|
||||
|
||||
// Double-click renames — the affordance every tabbed interface already has, and
|
||||
// it keeps the bar free of a per-tab edit button.
|
||||
_renderTab(tab) {
|
||||
const label = this._tabLabel(tab);
|
||||
return html`
|
||||
<div
|
||||
class="copilot-tab ${tab.key === this._activeKey ? 'copilot-tab--active' : ''}"
|
||||
@click=${() => this._selectTab(tab.key)}
|
||||
@dblclick=${e => this._startRename(tab.key, e)}
|
||||
title=${label}
|
||||
>
|
||||
${this._renamingKey === tab.key ? html`
|
||||
<input
|
||||
class="copilot-tab-rename"
|
||||
.value=${tab.title ?? ''}
|
||||
placeholder=${label}
|
||||
@click=${e => e.stopPropagation()}
|
||||
@keydown=${e => this._onRenameKey(tab.key, e)}
|
||||
@blur=${e => this._commitRename(tab.key, e.target.value)}
|
||||
>
|
||||
` : html`
|
||||
<span class="copilot-tab-label">${label}</span>
|
||||
${tab.key !== `src:${GENERAL_SOURCE}` ? html`
|
||||
<button class="copilot-tab-close" title=${t('chat.close_tab')}
|
||||
@click=${e => this._closeTab(tab.key, e)}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── The `+` menu ────────────────────────────────────────────────────────────
|
||||
|
||||
async _toggleNewTab() {
|
||||
this._newTabOpen = !this._newTabOpen;
|
||||
if (!this._newTabOpen || this._newTabTargets) return;
|
||||
// General plus the caller's projects — the two things a chat can be *about*.
|
||||
// A project entry starts a second conversation there, with the coordinator
|
||||
// agent and the project's context, exactly like its own tab.
|
||||
let projects = [];
|
||||
try {
|
||||
const res = await fetch('/api/projects');
|
||||
if (res.ok) projects = await res.json();
|
||||
} catch { /* the General entry is still useful */ }
|
||||
this._newTabTargets = [
|
||||
{ source: GENERAL_SOURCE, label: t('chat.tab.general') },
|
||||
...projects.map(p => ({ source: `project-${p.id}`, label: p.name })),
|
||||
];
|
||||
}
|
||||
|
||||
async _openNewTab(target) {
|
||||
this._newTabOpen = false;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/sessions/new?source=${encodeURIComponent(target.source)}`, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const row = await res.json();
|
||||
const tab = secondaryTab(row.source, row.session_id, row.label || target.label, null);
|
||||
this._tabs = [...this._tabs, tab];
|
||||
this._setCollapsed(false);
|
||||
this._selectTab(tab.key);
|
||||
} catch (e) {
|
||||
this._pushError('Could not open a new chat: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Renaming ────────────────────────────────────────────────────────────────
|
||||
|
||||
_startRename(key, e) {
|
||||
e?.stopPropagation();
|
||||
this._renamingKey = key;
|
||||
this.updateComplete.then(() => {
|
||||
const input = this.querySelector('.copilot-tab-rename');
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
_onRenameKey(key, e) {
|
||||
if (e.key === 'Enter') { e.preventDefault(); this._commitRename(key, e.target.value); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); this._renamingKey = null; }
|
||||
}
|
||||
|
||||
// An empty name clears the title, which gives back the automatic label rather
|
||||
// than a blank tab — so the box is also the way to undo a rename.
|
||||
async _commitRename(key, value) {
|
||||
this._renamingKey = null;
|
||||
const tab = this._tabs.find(t => t.key === key);
|
||||
if (!tab?.sessionId) return;
|
||||
const title = value.trim();
|
||||
if ((tab.title ?? '') === title) return;
|
||||
tab.title = title || null;
|
||||
this.requestUpdate();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${tab.sessionId}/title`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title: title || null }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
} catch (e) {
|
||||
console.error('Failed to rename the chat:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// What a tab prints. A user-set title always wins. Without one, secondary tabs
|
||||
// on the same source would all read "General" — so they are numbered by their
|
||||
// position among their siblings, which is stable and needs no extra state.
|
||||
_tabLabel(tab) {
|
||||
if (tab.title) return tab.title;
|
||||
if (tab.primary) return tab.label;
|
||||
const siblings = this._tabs.filter(t => !t.primary && t.source === tab.source);
|
||||
return `${tab.label} ${siblings.indexOf(tab) + 2}`;
|
||||
}
|
||||
|
||||
// Best-effort: a tab that failed to persist reappears (or lingers) at the next
|
||||
// login, which is a nuisance, never a loss.
|
||||
async _persistTab(sessionId, open) {
|
||||
@@ -469,25 +645,26 @@ export class AppCopilot extends I18nMixin(ChatSession) {
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
${this._tabs.length > 1 ? html`
|
||||
<div class="copilot-tabs">
|
||||
${this._tabs.map(tab => html`
|
||||
<div
|
||||
class="copilot-tab ${tab.source === this._source ? 'copilot-tab--active' : ''}"
|
||||
@click=${() => this._selectTab(tab.source)}
|
||||
title=${tab.label}
|
||||
>
|
||||
<span class="copilot-tab-label">${tab.label}</span>
|
||||
${tab.source !== 'web' ? html`
|
||||
<button class="copilot-tab-close" title=${t('chat.close_tab')}
|
||||
@click=${e => this._closeTab(tab.source, e)}>
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
` : nothing}
|
||||
<div class="copilot-tabs">
|
||||
${this._tabs.map(tab => this._renderTab(tab))}
|
||||
<div class="copilot-tab-new">
|
||||
<button class="copilot-tab-add" title=${t('chat.new_tab')}
|
||||
@click=${() => this._toggleNewTab()}>
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
${this._newTabOpen ? html`
|
||||
<div class="copilot-model-overlay" @click=${() => { this._newTabOpen = false; }}></div>
|
||||
<div class="copilot-tab-menu">
|
||||
${this._newTabTargets === null
|
||||
? html`<div class="copilot-tab-menu-empty">${t('chat.new_tab.loading')}</div>`
|
||||
: this._newTabTargets.map(target => html`
|
||||
<button class="copilot-tab-menu-item"
|
||||
@click=${() => this._openNewTab(target)}>${target.label}</button>
|
||||
`)}
|
||||
</div>
|
||||
`)}
|
||||
` : nothing}
|
||||
</div>
|
||||
` : nothing}
|
||||
</div>
|
||||
|
||||
<div class="copilot-messages">
|
||||
${this._messages.length === 0
|
||||
|
||||
@@ -47,10 +47,11 @@ export class ChatPage extends ChatSession {
|
||||
this._forceScrollToBottom();
|
||||
}
|
||||
// The owner (mobile-app) re-points this chat by changing `source`. Switch the
|
||||
// live connection — base `_switchSource` tears down the WS, reloads that
|
||||
// source's history, and reconnects. The guard skips the initial no-op render.
|
||||
// live connection — base `_switchTo` tears down the WS, reloads that source's
|
||||
// history, and reconnects. The guard skips the initial no-op render. Mobile
|
||||
// has no tab bar, so it only ever addresses a chat by source.
|
||||
if (changed.has('source') && this.source !== this._source) {
|
||||
this._switchSource(this.source);
|
||||
this._switchTo(this.source);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -241,3 +241,81 @@ app-copilot[mode="full"] .copilot-msg {
|
||||
.copilot-tab-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Inline rename: sits in the tab's own box so the bar doesn't reflow while
|
||||
typing. Sized off the label so a long name still scrolls within its tab. */
|
||||
.copilot-tab-rename {
|
||||
width: 100%;
|
||||
min-width: 80px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 0.2rem;
|
||||
font: inherit;
|
||||
color: var(--bs-body-color, inherit);
|
||||
background: var(--bs-body-bg, #fff);
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
/* ── New-chat menu ──────────────────────────────────────────────────────────── */
|
||||
.copilot-tab-new {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.copilot-tab-add {
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.2rem 0.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.copilot-tab-add:hover {
|
||||
background: var(--toolbar-border);
|
||||
color: var(--bs-body-color, inherit);
|
||||
}
|
||||
|
||||
.copilot-tab-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
min-width: 180px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid var(--toolbar-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bs-body-bg, #fff);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 12%);
|
||||
}
|
||||
|
||||
.copilot-tab-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.82rem;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
color: inherit;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.copilot-tab-menu-item:hover {
|
||||
background: var(--toolbar-border);
|
||||
}
|
||||
|
||||
.copilot-tab-menu-empty {
|
||||
padding: 0.4rem 0.6rem;
|
||||
font-size: 0.82rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ export default {
|
||||
'chat.security_group': 'Security group',
|
||||
'chat.collapse': 'Hide chat',
|
||||
'chat.close_tab': 'Close tab',
|
||||
'chat.new_tab': 'New chat',
|
||||
'chat.new_tab.loading': 'Loading…',
|
||||
'chat.privacy': 'Private to you',
|
||||
'chat.privacy.hint': 'Only you can see this conversation. Sharing anything with the group always asks for approval first.',
|
||||
'chat.suggest.1': 'What can you do?',
|
||||
|
||||
@@ -70,6 +70,8 @@ export default {
|
||||
'chat.security_group': 'Groupe de sécurité',
|
||||
'chat.collapse': 'Masquer la discussion',
|
||||
'chat.close_tab': 'Fermer l\'onglet',
|
||||
'chat.new_tab': 'Nouvelle discussion',
|
||||
'chat.new_tab.loading': 'Chargement…',
|
||||
'chat.privacy': 'Privé pour vous',
|
||||
'chat.privacy.hint': 'Vous seul(e) pouvez voir cette conversation. Partager quoi que ce soit avec le groupe demande toujours une approbation préalable.',
|
||||
'chat.suggest.1': 'Que pouvez-vous faire ?',
|
||||
|
||||
@@ -70,6 +70,8 @@ export default {
|
||||
'chat.security_group': 'Gruppo di sicurezza',
|
||||
'chat.collapse': 'Nascondi la chat',
|
||||
'chat.close_tab': 'Chiudi scheda',
|
||||
'chat.new_tab': 'Nuova chat',
|
||||
'chat.new_tab.loading': 'Caricamento…',
|
||||
'chat.privacy': 'Privata',
|
||||
'chat.privacy.hint': 'Solo tu puoi vedere questa conversazione. Condividere qualcosa con il gruppo richiede sempre prima un\'approvazione.',
|
||||
'chat.suggest.1': 'Cosa sai fare?',
|
||||
|
||||
+57
-17
@@ -99,6 +99,9 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
// Runtime-selected source. When null, falls back to the static `_wsSource`.
|
||||
// Lets a single chat component switch between sessions (e.g. copilot tabs).
|
||||
this._activeSource = null;
|
||||
// When set, this chat is bound to one specific conversation rather than to
|
||||
// whatever its source currently points at. See `_apiBase`.
|
||||
this._activeSessionId = null;
|
||||
// Voice recording state. Shared so every surface (desktop copilot + mobile
|
||||
// chat) can expose the same mic button. The desktop-only Ctrl+Space push-
|
||||
// to-talk shortcut is wired in `app-copilot`; `_shortcutRecording` tracks
|
||||
@@ -153,17 +156,39 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
// Static default source for this component. Subclasses override (e.g. 'mobile').
|
||||
get _wsSource() { return 'web'; }
|
||||
|
||||
// The socket is addressed the same two ways as `_apiBase`. The source rides
|
||||
// along even for a session-addressed connection: the server still needs it for
|
||||
// `/sethome` and for tagging what it broadcasts.
|
||||
_wsQuery() {
|
||||
const q = `source=${encodeURIComponent(this._source)}`;
|
||||
return this._activeSessionId ? `${q}&session=${this._activeSessionId}` : q;
|
||||
}
|
||||
|
||||
// Effective source: the runtime-selected one, or the static default.
|
||||
get _source() { return this._activeSource ?? this._wsSource; }
|
||||
|
||||
/**
|
||||
* Switch the live connection to a different source: tear down the current WS,
|
||||
* swap source, reload that source's history, and reconnect. Used to move
|
||||
* between sessions (e.g. General ↔ a project chat) without remounting.
|
||||
* The REST prefix every per-chat call hangs off. Two ways to name a
|
||||
* conversation: through its source ("whatever `web` points at right now",
|
||||
* which is what background delivery reaches) or directly by id. An extra tab
|
||||
* is the second kind — its source points at a different conversation, so it
|
||||
* is unreachable by name.
|
||||
*/
|
||||
async _switchSource(source) {
|
||||
get _apiBase() {
|
||||
return this._activeSessionId
|
||||
? `/api/sessions/${this._activeSessionId}`
|
||||
: `/api/${this._source}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the live connection to another conversation: tear down the current
|
||||
* WS, rebind, reload that conversation's history, and reconnect. Used to move
|
||||
* between tabs without remounting. `sessionId` null ⇒ address it by source.
|
||||
*/
|
||||
async _switchTo(source, sessionId = null) {
|
||||
if (this._ws) { this._ws.onclose = null; this._ws.close(); this._ws = null; }
|
||||
this._activeSource = source;
|
||||
this._activeSource = source;
|
||||
this._activeSessionId = sessionId;
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
this._tasks = [];
|
||||
@@ -209,7 +234,7 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
|
||||
async _loadHistory() {
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/messages`);
|
||||
const res = await fetch(`${this._apiBase}/messages`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const items = await res.json();
|
||||
if (items.length > 0) {
|
||||
@@ -272,7 +297,7 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
|
||||
async _loadTasks() {
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/tasks`);
|
||||
const res = await fetch(`${this._apiBase}/tasks`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const dismissed = this._dismissedTasks();
|
||||
this._tasks = (await res.json()).filter(t => !dismissed.has(t.job_id));
|
||||
@@ -359,7 +384,7 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
|
||||
async _loadTaskInbox() {
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/inbox`);
|
||||
const res = await fetch(`${this._apiBase}/inbox`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
this._taskInbox = {
|
||||
@@ -428,7 +453,7 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
|
||||
_connectWS() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws?source=${this._source}`);
|
||||
const ws = new WebSocket(`${proto}://${location.host}/api/ws?${this._wsQuery()}`);
|
||||
this._ws = ws;
|
||||
ws.onopen = () => {
|
||||
// After an auto-reconnect, reconcile tool state: a terminal event
|
||||
@@ -496,7 +521,7 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
async _resyncOnReconnect() {
|
||||
let items;
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/messages`);
|
||||
const res = await fetch(`${this._apiBase}/messages`);
|
||||
if (!res.ok) return;
|
||||
items = await res.json();
|
||||
} catch { return; }
|
||||
@@ -539,10 +564,22 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
this._tasks = [];
|
||||
this._stopTaskClock();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions?source=${this._source}`, { method: 'POST' });
|
||||
// Two different meanings of "start over". A source-addressed chat resets its
|
||||
// source: the pointer moves to a fresh conversation, so background delivery
|
||||
// follows along. An extra tab has no pointer to move — resetting the source
|
||||
// would silently restart a *different* chat — so it opens one more
|
||||
// conversation and rebinds to it, leaving its source alone.
|
||||
const url = this._activeSessionId
|
||||
? `/api/sessions/new?source=${encodeURIComponent(this._source)}`
|
||||
: `/api/sessions?source=${encodeURIComponent(this._source)}`;
|
||||
const res = await fetch(url, { method: 'POST' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const { session_id } = await res.json();
|
||||
if (session_id) this._onSessionReplaced(this._source, session_id);
|
||||
if (session_id) {
|
||||
const previous = this._activeSessionId;
|
||||
if (previous) this._activeSessionId = session_id;
|
||||
this._onSessionReplaced(this._source, session_id, previous);
|
||||
}
|
||||
} catch (e) {
|
||||
this._pushError('Could not clear session: ' + e.message);
|
||||
}
|
||||
@@ -550,10 +587,12 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A reset replaced this source's session with a fresh one. Anything anchored to
|
||||
* the session id (the copilot's tab bar) has to follow it across. No-op here.
|
||||
* This chat is now a different conversation — either its source was reset, or an
|
||||
* extra tab started over. Anything anchored to the session id (the copilot's tab
|
||||
* bar) has to follow it across. `previous` is the id being left behind, null when
|
||||
* the chat was addressed by source. No-op here.
|
||||
*/
|
||||
_onSessionReplaced(_source, _sessionId) {}
|
||||
_onSessionReplaced(_source, _sessionId, _previous) {}
|
||||
|
||||
// ── Message handling ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -831,7 +870,8 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
this._messages = [];
|
||||
this._waiting = false;
|
||||
// A reset from another client of this source: follow the new session id.
|
||||
if (msg.session_id) this._onSessionReplaced(this._source, msg.session_id);
|
||||
// Source-addressed only — an extra tab never sees this event.
|
||||
if (msg.session_id) this._onSessionReplaced(this._source, msg.session_id, null);
|
||||
break;
|
||||
|
||||
case 'client_selected':
|
||||
@@ -1024,7 +1064,7 @@ export class ChatSession extends InboxCardsMixin(LightElement) {
|
||||
for (const f of list) form.append('files', f, f.name);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/${this._source}/uploads`, { method: 'POST', body: form });
|
||||
const res = await fetch(`${this._apiBase}/uploads`, { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const saved = await res.json(); // [{ name, path, mimetype, filesize }]
|
||||
// Replace the placeholders with the saved entries (preserve other chips).
|
||||
|
||||
Reference in New Issue
Block a user