refactor(tabs): slim Tab to pointer-only state on v2 storage key

This commit is contained in:
Anish Sarkar 2026-07-16 18:10:47 +05:30
parent 88b9632911
commit a8fb161bef

View file

@ -1,22 +1,13 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils"; import { atomWithStorage, createJSONStorage } from "jotai/utils";
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
import { migrateLegacyTabs } from "./migrate-tabs";
export type TabType = "chat" | "document"; export type TabType = "chat" | "document";
export interface Tab { export interface Tab {
id: string; id: string;
type: TabType; type: TabType;
title: string; entityId: number | null;
/** For chat tabs */ workspaceId: number;
chatId?: number | null;
chatUrl?: string;
visibility?: ChatVisibility;
hasComments?: boolean;
/** For document tabs */
documentId?: number;
workspaceId?: number;
} }
interface TabsState { interface TabsState {
@ -27,9 +18,8 @@ interface TabsState {
const INITIAL_CHAT_TAB: Tab = { const INITIAL_CHAT_TAB: Tab = {
id: "chat-new", id: "chat-new",
type: "chat", type: "chat",
title: "New Chat", entityId: null,
chatId: null, workspaceId: 0,
chatUrl: undefined,
}; };
const initialState: TabsState = { const initialState: TabsState = {
@ -37,23 +27,14 @@ const initialState: TabsState = {
activeTabId: "chat-new", activeTabId: "chat-new",
}; };
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
// Persist tabs in localStorage so they survive a hard refresh and let the user // Persist tabs in localStorage so they survive a hard refresh and let the user
// keep tabs open across multiple workspaces (browser-like behavior). // keep tabs open across multiple workspaces (browser-like behavior).
const localStorageAdapter = createJSONStorage<TabsState>( const localStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage () => (typeof window !== "undefined" ? localStorage : undefined) as Storage
); );
// Wrap getItem in place so the adapter keeps its original (sync) type while
// migrating legacy persisted state on read.
const baseGetItem = localStorageAdapter.getItem.bind(localStorageAdapter);
localStorageAdapter.getItem = (key, initialValue) =>
migrateLegacyTabs(baseGetItem(key, initialValue));
export const tabsStateAtom = atomWithStorage<TabsState>( export const tabsStateAtom = atomWithStorage<TabsState>(
"surfsense:tabs", "surfsense:tabs:v2",
initialState, initialState,
localStorageAdapter, localStorageAdapter,
{ getOnInit: true } { getOnInit: true }
@ -66,11 +47,11 @@ export const activeTabAtom = atom((get) => {
return state.tabs.find((t) => t.id === state.activeTabId) ?? null; return state.tabs.find((t) => t.id === state.activeTabId) ?? null;
}); });
function makeChatTabId(chatId: number | null): string { export function makeChatTabId(chatId: number | null): string {
return chatId ? `chat-${chatId}` : "chat-new"; return chatId ? `chat-${chatId}` : "chat-new";
} }
function makeDocumentTabId(documentId: number): string { export function makeDocumentTabId(documentId: number): string {
return `doc-${documentId}`; return `doc-${documentId}`;
} }
@ -86,24 +67,12 @@ export const syncChatTabAtom = atom(
set, set,
{ {
chatId, chatId,
title,
chatUrl,
workspaceId, workspaceId,
visibility,
hasComments,
}: { }: {
chatId: number | null; chatId: number | null;
title?: string;
chatUrl?: string;
workspaceId: number; workspaceId: number;
visibility?: ChatVisibility;
hasComments?: boolean;
} }
) => { ) => {
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
return;
}
const state = get(tabsStateAtom); const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId); const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId); const existing = state.tabs.find((t) => t.id === tabId);
@ -116,11 +85,8 @@ export const syncChatTabAtom = atom(
t.id === tabId t.id === tabId
? { ? {
...t, ...t,
title: title || t.title, entityId: chatId,
chatUrl: chatUrl || t.chatUrl, workspaceId,
workspaceId: workspaceId ?? t.workspaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
} }
: t : t
), ),
@ -136,11 +102,13 @@ export const syncChatTabAtom = atom(
set(tabsStateAtom, { set(tabsStateAtom, {
...state, ...state,
activeTabId: "chat-new", activeTabId: "chat-new",
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, workspaceId, chatUrl } : t)), tabs: state.tabs.map((t) =>
t.id === "chat-new" ? { ...t, entityId: null, workspaceId } : t
),
}); });
} else { } else {
set(tabsStateAtom, { set(tabsStateAtom, {
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }], tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId }],
activeTabId: "chat-new", activeTabId: "chat-new",
}); });
} }
@ -152,12 +120,8 @@ export const syncChatTabAtom = atom(
const newTab: Tab = { const newTab: Tab = {
id: tabId, id: tabId,
type: "chat", type: "chat",
title: title || "New Chat", entityId: chatId,
chatId,
chatUrl,
workspaceId, workspaceId,
...(visibility !== undefined ? { visibility } : {}),
...(hasComments !== undefined ? { hasComments } : {}),
}; };
let updatedTabs: Tab[]; let updatedTabs: Tab[];
@ -172,29 +136,26 @@ export const syncChatTabAtom = atom(
} }
); );
/** Update the title of the current chat tab (e.g., when a chat gets its first response). */ /** Promote the lazy "new chat" tab once the server creates its thread. */
export const updateChatTabTitleAtom = atom( export const updateChatTabTitleAtom = atom(
null, null,
(get, set, { chatId, title }: { chatId: number; title: string }) => { (get, set, { chatId }: { chatId: number; title?: string }) => {
const state = get(tabsStateAtom); const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId); const tabId = makeChatTabId(chatId);
const hasExactTab = state.tabs.some((t) => t.id === tabId); const hasExactTab = state.tabs.some((t) => t.id === tabId);
// During lazy thread creation, title updates can arrive before "chat-new" // During lazy thread creation, title updates can arrive before "chat-new" is
// is swapped to chat-{id}. In that case, promote the active "chat-new" tab. // swapped to chat-{id}. In that case, promote the pointer only; title comes
// from Zero.
if (!hasExactTab && state.activeTabId === "chat-new") { if (!hasExactTab && state.activeTabId === "chat-new") {
set(tabsStateAtom, { set(tabsStateAtom, {
...state, ...state,
activeTabId: tabId, activeTabId: tabId,
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)), tabs: state.tabs.map((t) =>
t.id === "chat-new" ? { ...t, id: tabId, entityId: chatId } : t
),
}); });
return;
} }
set(tabsStateAtom, {
...state,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
});
} }
); );
@ -204,7 +165,7 @@ export const openDocumentTabAtom = atom(
( (
get, get,
set, set,
{ documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string } { documentId, workspaceId }: { documentId: number; workspaceId: number; title?: string }
) => { ) => {
const state = get(tabsStateAtom); const state = get(tabsStateAtom);
const tabId = makeDocumentTabId(documentId); const tabId = makeDocumentTabId(documentId);
@ -214,7 +175,7 @@ export const openDocumentTabAtom = atom(
set(tabsStateAtom, { set(tabsStateAtom, {
...state, ...state,
activeTabId: tabId, activeTabId: tabId,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title: title || t.title } : t)), tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, workspaceId } : t)),
}); });
return; return;
} }
@ -222,8 +183,7 @@ export const openDocumentTabAtom = atom(
const newTab: Tab = { const newTab: Tab = {
id: tabId, id: tabId,
type: "document", type: "document",
title: title || `Document ${documentId}`, entityId: documentId,
documentId,
workspaceId, workspaceId,
}; };
@ -254,11 +214,12 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => {
// Don't close the last tab — always keep at least one // Don't close the last tab — always keep at least one
if (remaining.length === 0) { if (remaining.length === 0) {
const closedTab = state.tabs[idx];
set(tabsStateAtom, { set(tabsStateAtom, {
tabs: [INITIAL_CHAT_TAB], tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId }],
activeTabId: "chat-new", activeTabId: "chat-new",
}); });
return INITIAL_CHAT_TAB; return { ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId };
} }
let newActiveId = state.activeTabId; let newActiveId = state.activeTabId;
@ -279,18 +240,16 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
const idx = state.tabs.findIndex((t) => t.id === tabId); const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return null; if (idx === -1) return null;
const deletedChatIds = get(deletedChatIdsAtom);
set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
const remaining = state.tabs.filter((t) => t.id !== tabId); const remaining = state.tabs.filter((t) => t.id !== tabId);
// Always keep at least one tab available. // Always keep at least one tab available.
if (remaining.length === 0) { if (remaining.length === 0) {
const removedTab = state.tabs[idx];
set(tabsStateAtom, { set(tabsStateAtom, {
tabs: [INITIAL_CHAT_TAB], tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId }],
activeTabId: "chat-new", activeTabId: "chat-new",
}); });
return INITIAL_CHAT_TAB; return { ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId };
} }
let newActiveId = state.activeTabId; let newActiveId = state.activeTabId;
@ -303,8 +262,34 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
return remaining.find((t) => t.id === newActiveId) ?? null; return remaining.find((t) => t.id === newActiveId) ?? null;
}); });
/** Reset tabs when switching workspaces. */ /** Remove unresolved chat pointers after Zero confirms the queried rows are complete. */
export const resetTabsAtom = atom(null, (_get, set) => { export const pruneMissingChatTabsAtom = atom(null, (get, set, missingChatIds: Set<number>) => {
set(tabsStateAtom, { ...initialState }); if (missingChatIds.size === 0) return;
set(deletedChatIdsAtom, new Set<number>());
const state = get(tabsStateAtom);
const firstMissingIdx = state.tabs.findIndex(
(t) => t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)
);
if (firstMissingIdx === -1) return;
const remaining = state.tabs.filter(
(t) => !(t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId))
);
if (remaining.length === 0) {
set(tabsStateAtom, {
tabs: [INITIAL_CHAT_TAB],
activeTabId: "chat-new",
});
return;
}
const activeWasPruned = state.tabs.some(
(t) => t.id === state.activeTabId && t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)
);
const newActiveId = activeWasPruned
? remaining[Math.min(firstMissingIdx, remaining.length - 1)].id
: state.activeTabId;
set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
}); });