From 8f5c5382c830d87d975bdcb4ee031fea30a14e8f Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Tue, 4 Aug 2026 21:50:10 +0100 Subject: [PATCH] feat: keep the chat tabs you left open, and keep them with you MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reopening the app closed every project tab: the copilot's tab bar lived in RAM, so a reload dropped it and each conversation had to be found again from its project board. The set of open tabs is now a column on the session row, `chat_sessions.is_open` (additive, `ensure_column`), restored by `GET /api/sessions/open` and written by `PUT /api/sessions/{id}/open`. Not localStorage: that store is per-origin, so on a shared laptop one member's tabs would greet the next, whereas the owner table sits in their own encrypted file and follows them to another device. Which tab is *selected* stays in sessionStorage — that one is genuinely per window, and a shared value would have two windows fighting over it. `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 the opposite default would restore a bar full of conversations nobody opened. Only the copilot writes the column, at the moment it opens the tab. A reset moves the flag rather than copying it — `POST /api/sessions` now returns the new id and `new_session` carries it, and the old row is closed as the new one opens, or the source would restore twice and a later close would clear the stale row. Closing a tab clears the flag and nothing else: the conversation is kept and comes back with its history when the project is reopened. --- CLAUDE.md | 2 + crates/skald-core/src/db/chat_sessions.rs | 73 +++++++++++++ crates/skald-core/src/db/mod.rs | 12 ++ docs/projects.md | 2 + src/frontend/api/mod.rs | 5 + src/frontend/api/sessions.rs | 85 ++++++++++++++- web/components/copilot.js | 127 ++++++++++++++++++++-- web/components/projects/project-board.js | 4 +- web/components/sidebar.js | 4 +- web/lib/chat-session.js | 10 ++ 10 files changed, 309 insertions(+), 15 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 95ffdcf..e394117 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -447,6 +447,8 @@ All extend `LightElement` from `web/lib/base.js` (Lit). `ChatSession` (`web/lib/ **The chat is the home page.** `` 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. + **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. **i18n** (`web/lib/i18n.js` + `web/i18n/{en,it,fr}.js`): `t(key)` helper, `I18nMixin` re-renders on `locale-changed`. Resolution order: user preference (`users.locale`, editable on the profile page) → instance default (registry config key `ui_locale`, editable by the admin in Settings — declared in `skald_core::i18n::config_set`) → English. **Server-side, never re-implement that chain**: `skald_core::i18n::resolve_locale(pool, user_locale)` is the one function (with `default_locale(pool)` and `language_name(locale)` for prompt rendering); they read through `db::config` because the bus only matters for writes and callers like the system-context source hold pools, not the manager. Pre-auth screens use the localStorage cache. Default locale is English. First-run setup asks the language in both shells — the console wizard writes `ui_locale` via `skald_core::i18n::set_default_locale` (no system bus exists there), the web setup page sends `locale` to `POST /api/setup/user`, which writes it through `GlobalConfigManager::set`. Supported locales are centralized in `skald_core::i18n::SUPPORTED_LOCALES` and enforced server-side on every write. Translated so far: chrome (sidebar/topbar), chat + approval cards, login/setup, profile, inbox; deep admin pages are still English (fallback is automatic per-key). Copy is the only place domain words may appear (§0.1). diff --git a/crates/skald-core/src/db/chat_sessions.rs b/crates/skald-core/src/db/chat_sessions.rs index 0461763..0ce3efd 100644 --- a/crates/skald-core/src/db/chat_sessions.rs +++ b/crates/skald-core/src/db/chat_sessions.rs @@ -56,6 +56,43 @@ pub async fn set_run_context( Ok(()) } +/// One conversation the copilot keeps as a tab. +pub struct OpenSession { + pub id: i64, + pub source: String, + /// User-facing name, when one has been set. Nothing writes it yet — the column + /// predates the tab bar, which falls back to the source's own label. + pub title: Option, +} + +/// Show or hide a conversation in the copilot's tab bar. +/// +/// `chat_sessions` lives in the caller's own encrypted file, so addressing a +/// session by id is already scoped to its owner: an id from another user's pool +/// simply isn't there, and the update matches no row. +pub async fn set_open(pool: &SqlitePool, id: i64, open: bool) -> anyhow::Result<()> { + sqlx::query("UPDATE chat_sessions SET is_open = ? WHERE id = ?") + .bind(open as i64) + .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> { + let rows = sqlx::query_as::<_, (i64, String, Option)>( + "SELECT id, source, title FROM chat_sessions WHERE is_open = 1 ORDER BY id", + ) + .fetch_all(pool) + .await?; + + Ok(rows + .into_iter() + .map(|(id, source, title)| OpenSession { id, source, title }) + .collect()) +} + pub async fn find_by_id(pool: &SqlitePool, id: i64) -> anyhow::Result> { let row = sqlx::query_as::<_, (i64, String, String, bool, bool, Option)>( "SELECT id, source, agent_id, is_interactive, is_ephemeral, run_context @@ -74,3 +111,39 @@ pub async fn find_by_id(pool: &SqlitePool, id: i64) -> anyhow::Result SqlitePool { + let pool = SqlitePool::connect("sqlite::memory:").await.unwrap(); + crate::db::create_owner_tables(&pool).await.unwrap(); + pool + } + + /// The property the `DEFAULT 0` exists for: a session is *not* a tab until the + /// copilot says so. Every `/new` leaves its predecessor behind and every + /// system-agent pass mints one, so the opposite default would restore a bar + /// full of conversations nobody asked to see. + #[tokio::test] + async fn a_session_is_not_a_tab_until_it_is_opened() { + let pool = owner_pool().await; + let a = create(&pool, "assistant", "web", true, false).await.unwrap(); + let b = create(&pool, "assistant", "project-1", true, false).await.unwrap(); + assert!(list_open(&pool).await.unwrap().is_empty()); + + set_open(&pool, b.id, true).await.unwrap(); + let open = list_open(&pool).await.unwrap(); + assert_eq!(open.len(), 1); + assert_eq!(open[0].id, b.id); + assert_eq!(open[0].source, "project-1"); + assert!(open[0].title.is_none(), "nothing writes titles yet"); + + // Closing a tab is not deleting a conversation. + set_open(&pool, b.id, false).await.unwrap(); + assert!(list_open(&pool).await.unwrap().is_empty()); + assert!(find_by_id(&pool, b.id).await.unwrap().is_some()); + assert!(find_by_id(&pool, a.id).await.unwrap().is_some()); + } +} diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 7ead150..72c1e91 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -776,12 +776,24 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> { agent_id TEXT NOT NULL DEFAULT 'main', is_interactive INTEGER NOT NULL DEFAULT 1, is_ephemeral INTEGER NOT NULL DEFAULT 0, + is_open INTEGER NOT NULL DEFAULT 0, run_context TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) )", ) .execute(pool) .await?; + // Which conversations the copilot shows as tabs — persisted here rather than in + // the browser so the set follows the person (a shared laptop can't leak one + // member's tabs to another) and stays inside their encrypted file. + // + // The default is deliberately **0**, not 1: every `/new` leaves its previous + // session behind, and every system-agent pass creates one, so `DEFAULT 1` would + // turn every historical row on an existing box into a tab at the next login. + // For the same reason `chat_sessions::create` doesn't set it — it also serves + // cron, channels and system agents. Only the copilot writes this column, at the + // moment it opens the tab. + ensure_column(pool, "chat_sessions", "is_open", "INTEGER NOT NULL DEFAULT 0").await?; sqlx::query( "CREATE TABLE IF NOT EXISTS chat_sessions_stack ( diff --git a/docs/projects.md b/docs/projects.md index f9e6a47..200de76 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -20,6 +20,8 @@ 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 Files tab A file explorer rooted at the project folder: diff --git a/src/frontend/api/mod.rs b/src/frontend/api/mod.rs index ff0824c..cb51e26 100644 --- a/src/frontend/api/mod.rs +++ b/src/frontend/api/mod.rs @@ -53,6 +53,11 @@ pub fn router() -> Router> { // Custom slash commands (file-based, read-only listing for autocomplete + /help) .route("/commands", get(commands::list)) .route("/sessions", get(sessions::list_sessions).post(sessions::create)) + // The copilot's tab bar: which conversations it shows, and the write path + // 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/{id}/open", put(sessions::set_open)) // 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)) diff --git a/src/frontend/api/sessions.rs b/src/frontend/api/sessions.rs index 6709529..27afe7b 100644 --- a/src/frontend/api/sessions.rs +++ b/src/frontend/api/sessions.rs @@ -30,7 +30,11 @@ pub struct CreateQuery { pub source: String, } -fn default_source() -> String { "web".to_string() } +/// The always-present "General" chat — the one source every web client has +/// without opening anything. +const DEFAULT_WEB_SOURCE: &str = "web"; + +fn default_source() -> String { DEFAULT_WEB_SOURCE.to_string() } pub async fn create( State(skald): State>, @@ -48,7 +52,84 @@ pub async fn create( Some(rc) => Some(rc), None => role_default_run_context(&skald, &auth.user_id).await?, }; - ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?; + // The id is returned so the caller can carry a tab over to the session that + // replaced the one it was showing — a reset mints a new row, and `is_open` + // lives on the row. + let session_id = ctx.chat_hub.provision_session(&q.source, &agent, rc.as_ref(), true).await?; + Ok(Json(json!({ "session_id": session_id }))) +} + +// ── The copilot's tab bar ───────────────────────────────────────────────────── +// +// Which conversations are open is stored on the session row (`is_open`), not in +// the browser: the set then follows the person across devices, and lands in their +// 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. +#[derive(Serialize)] +pub struct OpenTab { + pub session_id: i64, + pub source: String, + pub label: Option, +} + +pub async fn list_open_tabs( + State(skald): State>, + Extension(auth): Extension, +) -> Result>, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + let rows = chat_sessions::list_open(&ctx.pool).await?; + + 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 { + continue; + } + let label = match row.title { + Some(t) => Some(t), + None => project_label(&skald, &row.source).await, + }; + tabs.push(OpenTab { session_id: row.id, source: row.source, label }); + } + Ok(Json(tabs)) +} + +/// 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 +/// degrade. +async fn project_label(skald: &Arc, source: &str) -> Option { + let id = source + .strip_prefix(super::projects::PROJECT_SOURCE_PREFIX)? + .parse::() + .ok()?; + skald_core::db::projects::get(skald.db(), id) + .await + .ok() + .flatten() + .map(|p| p.name) +} + +#[derive(Deserialize)] +pub struct SetOpenBody { + pub open: bool, +} + +pub async fn set_open( + State(skald): State>, + Extension(auth): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, ApiError> { + let ctx = require_context(&skald, &auth.user_id).await?; + // No ownership check needed: the session lives in the caller's own pool, so an + // id belonging to anyone else matches no row here. + chat_sessions::set_open(&ctx.pool, id, body.open).await?; Ok(Json(json!({}))) } diff --git a/web/components/copilot.js b/web/components/copilot.js index 6920393..e68f1d8 100644 --- a/web/components/copilot.js +++ b/web/components/copilot.js @@ -20,6 +20,17 @@ const SYSTEM_COMMAND_ITEMS = [ { name: 'sethome', description: () => t('copilot.cmd.sethome') }, ]; +// The always-present General tab. It is never stored as an open tab: it exists +// because the copilot exists, and it cannot be closed. +const GENERAL_SOURCE = 'web'; + +// Which tab is selected is per browser window, so it lives in sessionStorage — +// two windows would otherwise fight over one value, and every tab click would be +// a write. The *set* of open tabs is server-side (`chat_sessions.is_open`), which +// is why it follows the user across devices and a second household member on the +// same browser never sees it. +const ACTIVE_TAB_KEY = 'copilot-active-tab'; + export class AppCopilot extends I18nMixin(ChatSession) { static properties = { _collapsed: { state: true }, @@ -50,7 +61,9 @@ export class AppCopilot extends I18nMixin(ChatSession) { 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. - this._tabs = [{ source: 'web', label: t('chat.tab.general') }]; + // 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') }]; this._onResizeMove = this._onResizeMove.bind(this); this._onResizeUp = this._onResizeUp.bind(this); this._onKeydown = this._onKeydown.bind(this); @@ -65,8 +78,18 @@ export class AppCopilot extends I18nMixin(ChatSession) { get _canOpenTaskSession() { return true; } connectedCallback() { - super.connectedCallback?.(); + // Before super: the base loads history and opens the WS for `_source` in its + // own connectedCallback, so the restored selection has to be in place or the + // 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; + // 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?.(); this._restoreState(); + this._restoreTabs(ready); this._loadCommands(); this._loadMe(); this._loadSecurityGroups(); @@ -134,31 +157,117 @@ export class AppCopilot extends I18nMixin(ChatSession) { // ── Tabs ──────────────────────────────────────────────────────────────────── + // 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 + // opens on mount — otherwise both run and the connection is left doubled. + const settled = Promise.resolve(ready).catch(() => {}); + let tabs = []; + try { + const res = await fetch('/api/sessions/open'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + tabs = 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); + 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); + 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); + } + } + // 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. _onProjectChatOpen(e) { - const { source, label } = e.detail ?? {}; + const { source, label, session_id } = e.detail ?? {}; if (!source) return; - if (!this._tabs.some(t => t.source === source)) { - this._tabs = [...this._tabs, { source, label: label || source }]; + const known = this._tabs.find(t => t.source === source); + 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._persistTab(session_id, true); } this._setCollapsed(false); this._selectTab(source); } _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 } - // Close a project tab (UI only — the session persists server-side and can be - // reopened from the board). The 'web'/General tab is never closable. + // 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) { e?.stopPropagation(); - if (source === 'web') return; + 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); - if (wasActive) this._switchSource('web'); + this._persistTab(tab?.sessionId, false); + if (wasActive) this._selectTab(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); + } + + // 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 + // stale row would be the one a later close cleared. + _bindTabSession(tab, sessionId) { + if (!sessionId || tab.sessionId === sessionId) return; + const previous = tab.sessionId; + tab.sessionId = sessionId; + if (previous) this._persistTab(previous, false); + this._persistTab(sessionId, true); + } + + // 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) { + if (!sessionId) return; + try { + await fetch(`/api/sessions/${sessionId}/open`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ open }), + }); + } catch (e) { + console.error('Failed to persist copilot tab:', e); + } } // ── DOM hooks ───────────────────────────────────────────────────────────────── diff --git a/web/components/projects/project-board.js b/web/components/projects/project-board.js index 7f2a589..4f07514 100644 --- a/web/components/projects/project-board.js +++ b/web/components/projects/project-board.js @@ -139,9 +139,9 @@ export class ProjectBoardSection extends LightElement { try { const res = await fetch(`/api/projects/${this._projectId}/session`, { method: 'POST' }); if (!res.ok) throw new Error(await res.text()); - const { source } = await res.json(); + const { source, session_id } = await res.json(); window.dispatchEvent(new CustomEvent('project-chat-open', { - detail: { source, label: this._project?.name ?? `Project ${this._projectId}` }, + detail: { source, session_id, label: this._project?.name ?? `Project ${this._projectId}` }, })); } catch (e) { this._error = e.message; diff --git a/web/components/sidebar.js b/web/components/sidebar.js index e3f3552..388740c 100644 --- a/web/components/sidebar.js +++ b/web/components/sidebar.js @@ -186,9 +186,9 @@ export class AppSidebar extends I18nMixin(LightElement) { try { const res = await fetch(`/api/projects/${projectId}/session`, { method: 'POST' }); if (!res.ok) return; - const { source } = await res.json(); + const { source, session_id } = await res.json(); window.dispatchEvent(new CustomEvent('project-chat-open', { - detail: { source, label: projectName }, + detail: { source, session_id, label: projectName }, })); } catch { /* ignore */ } } diff --git a/web/lib/chat-session.js b/web/lib/chat-session.js index 4e6ae1a..010e2fb 100644 --- a/web/lib/chat-session.js +++ b/web/lib/chat-session.js @@ -541,12 +541,20 @@ export class ChatSession extends InboxCardsMixin(LightElement) { try { const res = await fetch(`/api/sessions?source=${this._source}`, { 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); } catch (e) { this._pushError('Could not clear session: ' + e.message); } this._connectWS(); } + /** + * 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. + */ + _onSessionReplaced(_source, _sessionId) {} + // ── Message handling ────────────────────────────────────────────────────────── _handleServerMsg(msg) { @@ -822,6 +830,8 @@ export class ChatSession extends InboxCardsMixin(LightElement) { this._cancelStreamFlush(); 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); break; case 'client_selected':