feat: keep the chat tabs you left open, and keep them with you
Nightly Build / build (push) Successful in 7m39s

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.
This commit is contained in:
2026-08-04 21:50:10 +01:00
parent 01b8a187b5
commit 8f5c5382c8
10 changed files with 309 additions and 15 deletions
+73
View File
@@ -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<String>,
}
/// 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<Vec<OpenSession>> {
let rows = sqlx::query_as::<_, (i64, String, Option<String>)>(
"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<Option<ChatSession>> {
let row = sqlx::query_as::<_, (i64, String, String, bool, bool, Option<String>)>(
"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<Option<Cha
run_context,
}))
}
#[cfg(test)]
mod tests {
use super::*;
async fn owner_pool() -> 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());
}
}
+12
View File
@@ -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 (