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
+118 -9
View File
@@ -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 ─────────────────────────────────────────────────────────────────
+2 -2
View File
@@ -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;
+2 -2
View File
@@ -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 */ }
}