Files
webmail/stores/pro-tab-store.ts
Linus Rath 3ef16f602e fix: keep a message's label through Undo send and take over labels only from internal senders
A sent message brought back by Undo, Cancel and edit or edit-as-new
reopened with the default label, because only drafts carried the
X-Bulwark-Label marker; it now falls back to the SIO-Label it went out
with. A reply, quick reply or plugin forward takes over the original's
label only when an internal sender set it and never one below the
default, since anyone outside can write the header. The tests written
for the earlier rule (any received label counts) now pass an internal
sender, which is why their originals changed.
2026-11-01 06:48:01 +01:00

743 lines
27 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ComposerDraftData } from '@/components/email/email-composer';
import type { ComposePrefill } from '@/lib/plugin-compose-gate';
import type { EmailWriteSource } from '@/stores/email-store';
import type { SearchScope } from '@/lib/global-search/query-parser';
export type ProTabKind =
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
| 'compose' | 'email' | 'folder' | 'search';
export type ProAppTabKind = Exclude<ProTabKind, 'compose' | 'email' | 'folder' | 'search'>;
export type ProPaneId = 'main' | 'split';
/** Which visual side of the shell the split pane renders on. Persisted, so a reload never flips the layout. */
export type ProSplitSide = 'left' | 'right';
/**
* Pro split layout. Only side-by-side is supported - the pane that "splits
* off" always lives next to the main pane on the horizontal axis. Kept as
* a type alias to leave room for future layouts without churning callers.
*/
export type ProSplitOrientation = 'vertical';
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Mirror of `EmailComposer.replyTo` - kept as a structural type here so the
* tab store doesn't take a runtime dependency on the composer module.
*/
export interface ProReplyContext {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
deliveredTo?: string[];
subject?: string;
body?: string;
htmlBody?: string;
receivedAt?: string;
accountId?: string;
attachments?: Array<{
blobId: string; name?: string; type: string; size: number;
cid?: string; disposition?: string;
}>;
messageId?: string;
inReplyTo?: string[];
references?: string[];
quoteHeaderHtml?: string;
quoteHeaderText?: string;
quoteWrapInBlockquote?: boolean;
/** The original's label headers (lib/outgoing/classification.ts labelSourceHeaders). */
headers?: Record<string, string | string[]>;
/** The original's From for a forward as attachment, whose `from` stays empty. */
originalFrom?: { email?: string; name?: string }[];
}
export interface ProComposeTabData {
sessionId: number;
mode: ProComposerMode;
replyTo?: ProReplyContext;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
/** Fields a plugin filled in through onBeforeCompose; dropped on the first state save. */
prefill?: ComposePrefill | null;
sourceEmailId?: string | null;
/** The login and account of that message, for its $answered / $forwarded flag. */
sourceEmailSource?: EmailWriteSource | null;
title: string;
}
export interface ProEmailTabData {
accountId: string;
emailId: string;
mailboxId: string | null;
title: string;
/**
* Connected-account id (login) whose client fetches this email, with
* `accountId` the JMAP account there; unset = the active account. Set for
* a list row and a hit from another login (global search) - bare email ids
* are ambiguous across logins (#847).
*/
clientAccountId?: string;
}
export interface ProFolderTabData {
/**
* Connected-account id the folder belongs to (multi-account sidebar
* groups); null = the active account. Used to resolve the JMAP client the
* folder tab fetches through.
*/
accountId: string | null;
mailboxId: string;
title: string;
}
export interface ProSearchTabData {
/** The submitted query, exactly as typed (operators included). */
query: string;
scope: SearchScope;
/** Connected-account id to restrict to; null = every login. */
accountId: string | null;
title: string;
}
export interface ProTab {
id: string;
kind: ProTabKind;
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email/folder. */
labelKey: string;
title?: string;
closeable: boolean;
composeData?: ProComposeTabData;
emailData?: ProEmailTabData;
folderData?: ProFolderTabData;
searchData?: ProSearchTabData;
/** Which pane this tab lives in. Defaults to 'main' for the single-pane case. */
paneId: ProPaneId;
}
export interface OpenEmailTabOptions {
/**
* Force the tab into a specific pane. Passing 'split' when no split exists
* creates one (used by the shell's drop zones and by double-click routing).
*/
pane?: ProPaneId;
/**
* Reuse the shared "reader" email tab in the target pane instead of piling
* up a new tab per opened message (VS Code preview-tab behaviour). The
* reader tab is whatever email tab this flow created last; explicit flows
* that want a durable tab simply don't pass this.
*/
reuseReader?: boolean;
}
/**
* The data half of the store - everything `normalizeProTabState` operates on.
* Kept separate from the actions so the normalizer stays a pure function.
*/
export interface ProTabCoreState {
tabs: ProTab[];
/** Active tab in each pane. `split` is null when there is no split. */
activeTabId: string;
activeSplitTabId: string | null;
/** When the user last clicked into a tab/body, which pane was it? */
focusedPaneId: ProPaneId;
/** Derived: non-null exactly when the split pane has tabs. */
splitOrientation: ProSplitOrientation | null;
splitSide: ProSplitSide;
/** The reusable double-click/drop "reader" email tab, if it still exists. */
readerTabId: string | null;
loadedTabIds: string[];
}
interface ProTabState extends ProTabCoreState {
openTab: (kind: ProAppTabKind) => string;
openComposeTab: (data: ProComposeTabData) => string;
openEmailTab: (data: ProEmailTabData, opts?: OpenEmailTabOptions) => string;
/**
* Open a mailbox in its own tab (sidebar folder dragged onto a tab strip
* or a pane). One tab per folder: opening an already-open folder focuses
* its tab instead of duplicating it.
*/
openFolderTab: (data: ProFolderTabData, opts?: { pane?: ProPaneId }) => string;
/**
* Opens a search-results tab (global search, #641). One tab per submitted
* query: re-submitting an identical query focuses the existing tab.
*/
openSearchTab: (data: ProSearchTabData, opts?: { pane?: ProPaneId }) => string;
/** Refining the query inside a search tab renames and re-scopes that tab. */
updateSearchTab: (id: string, data: Partial<ProSearchTabData>) => void;
closeTab: (id: string) => void;
/**
* Request closing a tab, honouring any registered close interceptor (e.g. a
* compose tab with unsaved changes that wants to show the "Save or discard
* draft?" dialog first). Falls back to `closeTab` when none is registered.
*/
requestCloseTab: (id: string) => void;
setActiveTab: (id: string) => void;
setFocusedPane: (paneId: ProPaneId) => void;
setSplitSide: (side: ProSplitSide) => void;
/**
* Move a tab next to another tab. `edge` controls whether it lands before
* or after the target - used by the tab bar's drop indicator. Reordering
* works both within a pane and across panes (cross-pane drops move the
* tab to the target pane).
*/
reorderTab: (draggedId: string, targetTabId: string, edge: 'before' | 'after') => void;
/**
* Move a tab to a specific pane. Moving into `split` when no split exists
* opens one (on `opts.side` if given, else the persisted side). Moving the
* last main-pane tab into the split merges the panes instead of refusing -
* no drag is ever a silent no-op.
*/
moveTabToPane: (tabId: string, paneId: ProPaneId, opts?: { side?: ProSplitSide }) => void;
/** Collapse the split: every split-pane tab returns to main. */
collapseSplit: () => void;
updateTabTitle: (id: string, title: string) => void;
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
}
const TAB_BLUEPRINTS: Record<ProAppTabKind, { labelKey: string }> = {
mail: { labelKey: 'mail' },
calendar: { labelKey: 'calendar' },
contacts: { labelKey: 'contacts' },
files: { labelKey: 'files' },
settings: { labelKey: 'settings' },
};
const HOME_TAB: ProTab = {
id: 'home-mail',
kind: 'mail',
labelKey: TAB_BLUEPRINTS.mail.labelKey,
closeable: false,
paneId: 'main',
};
/**
* Module-level registry of tab close interceptors. Kept outside the persisted
* Zustand state so functions are never serialised. A compose tab registers a
* handler here so an external close request (tab-bar "X", middle-click) routes
* through the composer's unsaved-changes guard instead of closing instantly.
*/
const closeInterceptors = new Map<string, () => void>();
export function registerProTabCloseInterceptor(id: string, fn: () => void): () => void {
closeInterceptors.set(id, fn);
return () => {
if (closeInterceptors.get(id) === fn) closeInterceptors.delete(id);
};
}
function makeId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `pro-tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function neighborInPane(tabs: ProTab[], removedId: string, paneId: ProPaneId): string | null {
const inPane = tabs.filter((t) => t.paneId === paneId);
const idx = inPane.findIndex((t) => t.id === removedId);
if (idx === -1) return inPane[0]?.id ?? null;
return (inPane[idx + 1] ?? inPane[idx - 1])?.id ?? null;
}
/**
* The single invariant enforcer. Every mutation builds its intended next
* state and runs it through here; rehydration runs through here too. The
* guarantees (see the Pro shell plan):
*
* I1 At least one tab always exists (the pinned home Mail tab is
* recreated if the list somehow empties).
* I2 Every tab belongs to a pane; the split pane exists iff it has tabs.
* I3 Each pane's active tab id points at a tab in that pane.
* I4 If main empties while split has tabs, the split pane *becomes* the
* main pane (pane merge) - callers never have to special-case it.
* I5 `focusedPaneId` points at an existing pane.
* I6 `loadedTabIds` contains both active tabs and only existing tabs.
*
* Pure function - exported for direct unit/property testing.
*/
export function normalizeProTabState(input: ProTabCoreState): ProTabCoreState {
let tabs = input.tabs.map((t) => (t.paneId ? t : { ...t, paneId: 'main' as const }));
// I1: never empty.
if (tabs.length === 0) {
return {
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
activeSplitTabId: null,
focusedPaneId: 'main',
splitOrientation: null,
splitSide: input.splitSide === 'left' ? 'left' : 'right',
readerTabId: null,
loadedTabIds: [HOME_TAB.id],
};
}
let activeTabId = input.activeTabId;
let activeSplitTabId = input.activeSplitTabId;
// I4: main never sits empty next to a populated split - the split pane
// takes over as main. The user's split-side focus carries across.
if (!tabs.some((t) => t.paneId === 'main')) {
tabs = tabs.map((t) => (t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t));
if (activeSplitTabId) activeTabId = activeSplitTabId;
activeSplitTabId = null;
}
const mainTabs = tabs.filter((t) => t.paneId === 'main');
const splitTabs = tabs.filter((t) => t.paneId === 'split');
const hasSplit = splitTabs.length > 0;
// I3: per-pane active ids must exist in their pane.
if (!mainTabs.some((t) => t.id === activeTabId)) {
activeTabId = mainTabs[0].id;
}
if (!hasSplit) {
activeSplitTabId = null;
} else if (!splitTabs.some((t) => t.id === activeSplitTabId)) {
activeSplitTabId = splitTabs[0].id;
}
// I2 (derived flag) + I5.
const splitOrientation: ProSplitOrientation | null = hasSplit ? 'vertical' : null;
const focusedPaneId: ProPaneId =
input.focusedPaneId === 'split' && hasSplit ? 'split' : 'main';
// Reader tab must still exist and still be an email tab.
const readerTabId = tabs.some((t) => t.id === input.readerTabId && t.kind === 'email')
? input.readerTabId
: null;
// I6: prune ghosts, keep both actives renderable.
const ids = new Set(tabs.map((t) => t.id));
let loadedTabIds = input.loadedTabIds.filter((id) => ids.has(id));
if (!loadedTabIds.includes(activeTabId)) loadedTabIds = [...loadedTabIds, activeTabId];
if (activeSplitTabId && !loadedTabIds.includes(activeSplitTabId)) {
loadedTabIds = [...loadedTabIds, activeSplitTabId];
}
return {
tabs,
activeTabId,
activeSplitTabId,
focusedPaneId,
splitOrientation,
splitSide: input.splitSide === 'left' ? 'left' : 'right',
readerTabId,
loadedTabIds,
};
}
/** True when the (normalized) state currently renders two panes. */
export function hasSplitPane(state: Pick<ProTabCoreState, 'tabs'>): boolean {
return state.tabs.some((t) => t.paneId === 'split');
}
function core(state: ProTabState): ProTabCoreState {
return {
tabs: state.tabs,
activeTabId: state.activeTabId,
activeSplitTabId: state.activeSplitTabId,
focusedPaneId: state.focusedPaneId,
splitOrientation: state.splitOrientation,
splitSide: state.splitSide,
readerTabId: state.readerTabId,
loadedTabIds: state.loadedTabIds,
};
}
/** Mark a tab active (and focused) in whichever pane it lives in. */
function withTabActivated(state: ProTabCoreState, tab: ProTab): ProTabCoreState {
return {
...state,
...(tab.paneId === 'main'
? { activeTabId: tab.id, focusedPaneId: 'main' as const }
: { activeSplitTabId: tab.id, focusedPaneId: 'split' as const }),
loadedTabIds: state.loadedTabIds.includes(tab.id)
? state.loadedTabIds
: [...state.loadedTabIds, tab.id],
};
}
export const useProTabStore = create<ProTabState>()(
persist(
(set, get) => {
/** Apply a mutation to the core state and commit the normalized result. */
const commit = (mutate: (s: ProTabCoreState) => ProTabCoreState) => {
set(normalizeProTabState(mutate(core(get()))));
};
return {
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
activeSplitTabId: null,
focusedPaneId: 'main',
splitOrientation: null,
splitSide: 'right',
readerTabId: null,
loadedTabIds: [HOME_TAB.id],
openTab: (kind) => {
const state = core(get());
// App tabs are singletons across the whole workspace: opening one
// that already exists focuses it wherever it lives (fixes the
// duplicate-Settings-tab bug), it never creates a second copy.
const existing = state.tabs.find((tab) => tab.kind === kind);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
const newTab: ProTab = {
id: makeId(),
kind,
labelKey: TAB_BLUEPRINTS[kind].labelKey,
closeable: true,
paneId: state.focusedPaneId,
};
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
openComposeTab: (data) => {
const newTab: ProTab = {
id: makeId(),
kind: 'compose',
labelKey: '',
title: data.title,
closeable: true,
composeData: data,
paneId: get().focusedPaneId,
};
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
openEmailTab: (data, opts) => {
const state = core(get());
// Same email already open anywhere: focus it, never open twice.
// Of the same login: ids and account ids repeat across servers.
const existing = state.tabs.find(
(tab) => tab.kind === 'email'
&& tab.emailData?.emailId === data.emailId
&& tab.emailData?.accountId === data.accountId
&& tab.emailData?.clientAccountId === data.clientAccountId
);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
const targetPane: ProPaneId = opts?.pane ?? state.focusedPaneId;
// Reuse the reader tab in the target pane: replace its content
// in place so double-click / drop flows drive a single tab
// instead of accumulating one per message.
if (opts?.reuseReader && state.readerTabId) {
const reader = state.tabs.find(
(t) => t.id === state.readerTabId && t.kind === 'email' && t.paneId === targetPane,
);
if (reader) {
commit((s) => withTabActivated({
...s,
tabs: s.tabs.map((t) => t.id === reader.id
? { ...t, emailData: data, title: data.title }
: t),
}, reader));
return reader.id;
}
}
const newTab: ProTab = {
id: makeId(),
kind: 'email',
labelKey: '',
title: data.title,
closeable: true,
emailData: data,
paneId: targetPane,
};
commit((s) => withTabActivated({
...s,
tabs: [...s.tabs, newTab],
readerTabId: opts?.reuseReader ? newTab.id : s.readerTabId,
}, newTab));
return newTab.id;
},
openFolderTab: (data, opts) => {
const state = core(get());
// Same folder already open anywhere: focus it, never open twice.
const existing = state.tabs.find(
(tab) => tab.kind === 'folder'
&& tab.folderData?.mailboxId === data.mailboxId
&& (tab.folderData?.accountId ?? null) === (data.accountId ?? null)
);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
const newTab: ProTab = {
id: makeId(),
kind: 'folder',
labelKey: '',
title: data.title,
closeable: true,
folderData: data,
paneId: opts?.pane ?? state.focusedPaneId,
};
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
openSearchTab: (data, opts) => {
const state = core(get());
// Same query already open: focus it, never open twice.
const existing = state.tabs.find(
(tab) => tab.kind === 'search'
&& tab.searchData?.query === data.query
&& tab.searchData?.scope === data.scope
&& (tab.searchData?.accountId ?? null) === (data.accountId ?? null)
);
if (existing) {
commit((s) => withTabActivated(s, existing));
return existing.id;
}
const newTab: ProTab = {
id: makeId(),
kind: 'search',
labelKey: '',
title: data.title,
closeable: true,
searchData: data,
paneId: opts?.pane ?? state.focusedPaneId,
};
commit((s) => withTabActivated({ ...s, tabs: [...s.tabs, newTab] }, newTab));
return newTab.id;
},
updateSearchTab: (id, data) => {
set({
tabs: get().tabs.map((tab) => {
if (tab.id !== id || tab.kind !== 'search' || !tab.searchData) return tab;
const searchData = { ...tab.searchData, ...data };
return { ...tab, searchData, title: searchData.title };
}),
});
},
requestCloseTab: (id) => {
const interceptor = closeInterceptors.get(id);
if (interceptor) {
interceptor();
return;
}
get().closeTab(id);
},
closeTab: (id) => {
const state = core(get());
const tab = state.tabs.find((t) => t.id === id);
if (!tab || !tab.closeable) return;
// Drop any registered close interceptor for this tab.
closeInterceptors.delete(id);
commit((s) => ({
...s,
tabs: s.tabs.filter((t) => t.id !== id),
// If the closed tab was active, hand its pane to a neighbour
// (looked up in the pre-close list, so it's the tab next door,
// not just the pane's first tab). normalize() covers the
// emptied-pane cases.
activeTabId: tab.paneId === 'main' && s.activeTabId === id
? (neighborInPane(s.tabs, id, 'main') ?? s.activeTabId)
: s.activeTabId,
activeSplitTabId: tab.paneId === 'split' && s.activeSplitTabId === id
? neighborInPane(s.tabs, id, 'split')
: s.activeSplitTabId,
}));
},
setActiveTab: (id) => {
const state = core(get());
const tab = state.tabs.find((t) => t.id === id);
if (!tab) return;
if (tab.paneId === 'main'
&& state.activeTabId === id && state.focusedPaneId === 'main') return;
if (tab.paneId === 'split'
&& state.activeSplitTabId === id && state.focusedPaneId === 'split') return;
commit((s) => withTabActivated(s, tab));
},
setFocusedPane: (paneId) => {
const state = core(get());
if (state.focusedPaneId === paneId) return;
commit((s) => ({ ...s, focusedPaneId: paneId }));
},
setSplitSide: (side) => {
if (get().splitSide === side) return;
commit((s) => ({ ...s, splitSide: side }));
},
reorderTab: (draggedId, targetTabId, edge) => {
const state = core(get());
if (draggedId === targetTabId) return;
const dragged = state.tabs.find((t) => t.id === draggedId);
const target = state.tabs.find((t) => t.id === targetTabId);
if (!dragged || !target) return;
commit((s) => {
const rest = s.tabs.filter((t) => t.id !== draggedId);
const insertAt = rest.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
const moved: ProTab = dragged.paneId === target.paneId
? dragged
: { ...dragged, paneId: target.paneId };
const tabs = [...rest];
tabs.splice(insertAt, 0, moved);
let next: ProTabCoreState = { ...s, tabs };
if (dragged.paneId !== target.paneId) {
// The old pane needs a new active tab if the dragged one was
// active there (its neighbour in the pre-move list); the new
// pane activates the dragged tab.
if (dragged.paneId === 'main' && s.activeTabId === draggedId) {
next = { ...next, activeTabId: neighborInPane(s.tabs, draggedId, 'main') ?? s.activeTabId };
}
if (dragged.paneId === 'split' && s.activeSplitTabId === draggedId) {
next = { ...next, activeSplitTabId: neighborInPane(s.tabs, draggedId, 'split') };
}
next = withTabActivated(next, moved);
}
return next;
});
},
moveTabToPane: (tabId, paneId, opts) => {
const state = core(get());
const tab = state.tabs.find((t) => t.id === tabId);
if (!tab) return;
if (tab.paneId === paneId) return;
const openingSplit = paneId === 'split' && !hasSplitPane(state);
commit((s) => {
const tabs = s.tabs.map((t) => (t.id === tabId ? { ...t, paneId } : t));
const moved = { ...tab, paneId };
let next: ProTabCoreState = {
...s,
tabs,
splitSide: openingSplit ? (opts?.side ?? s.splitSide) : s.splitSide,
};
// Hand the vacated pane to a neighbour (from the pre-move list)
// before activating the moved tab in its new pane. If the
// vacated pane emptied, normalize() collapses/merges as needed.
if (tab.paneId === 'main' && s.activeTabId === tabId) {
next = { ...next, activeTabId: neighborInPane(s.tabs, tabId, 'main') ?? s.activeTabId };
}
if (tab.paneId === 'split' && s.activeSplitTabId === tabId) {
next = { ...next, activeSplitTabId: neighborInPane(s.tabs, tabId, 'split') };
}
return withTabActivated(next, moved);
});
},
collapseSplit: () => {
const state = core(get());
if (!hasSplitPane(state)) return;
commit((s) => ({
...s,
tabs: s.tabs.map((t) => (t.paneId === 'split' ? { ...t, paneId: 'main' as const } : t)),
activeSplitTabId: null,
focusedPaneId: 'main',
}));
},
updateTabTitle: (id, title) => {
set({
tabs: get().tabs.map((tab) => (tab.id === id ? { ...tab, title } : tab)),
});
},
updateComposeDraft: (id, draft) => {
set({
tabs: get().tabs.map((tab) => {
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
// The draft now carries the user's own text: a remount must not
// apply the plugin's prefill over it again.
return { ...tab, composeData: { ...tab.composeData, initialData: draft, prefill: null } };
}),
});
},
};
},
{
name: 'pro-tabs',
version: 4,
// Don't persist transient compose drafts in tab metadata - the composer's
// own draft-store already handles that. Persisted email tabs are fine to
// restore (the tab body refetches the email by id).
partialize: (state) => ({
tabs: state.tabs.filter((tab) => tab.kind !== 'compose'),
activeTabId: state.activeTabId,
activeSplitTabId: state.activeSplitTabId,
focusedPaneId: state.focusedPaneId,
splitOrientation: state.splitOrientation,
splitSide: state.splitSide,
readerTabId: state.readerTabId,
loadedTabIds: state.loadedTabIds,
}),
migrate: (persisted, version) => {
// v3 -> v4: `splitSide` and `readerTabId` are new; earlier versions
// carried an un-persisted "which side" that always reset to the
// right. Everything else is repaired by normalize() on rehydrate.
const state = persisted as Partial<ProTabCoreState>;
if (version < 4) {
return {
...state,
splitSide: 'right' as const,
readerTabId: null,
};
}
return state;
},
onRehydrateStorage: () => (state) => {
if (!state) return;
const normalized = normalizeProTabState({
tabs: Array.isArray(state.tabs) ? state.tabs : [],
activeTabId: state.activeTabId,
activeSplitTabId: state.activeSplitTabId ?? null,
focusedPaneId: state.focusedPaneId ?? 'main',
splitOrientation: state.splitOrientation ?? null,
splitSide: state.splitSide ?? 'right',
readerTabId: state.readerTabId ?? null,
loadedTabIds: Array.isArray(state.loadedTabIds) ? state.loadedTabIds : [],
});
state.tabs = normalized.tabs;
state.activeTabId = normalized.activeTabId;
state.activeSplitTabId = normalized.activeSplitTabId;
state.focusedPaneId = normalized.focusedPaneId;
state.splitOrientation = normalized.splitOrientation;
state.splitSide = normalized.splitSide;
state.readerTabId = normalized.readerTabId;
state.loadedTabIds = normalized.loadedTabIds;
},
},
),
);