feat: keep the chat tabs you left open, and keep them with you
Nightly Build / build (push) Successful in 7m39s
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:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user