mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
refactor(tabs): slim Tab to pointer-only state on v2 storage key
This commit is contained in:
parent
88b9632911
commit
a8fb161bef
1 changed files with 61 additions and 76 deletions
|
|
@ -1,22 +1,13 @@
|
|||
import { atom } from "jotai";
|
||||
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 interface Tab {
|
||||
id: string;
|
||||
type: TabType;
|
||||
title: string;
|
||||
/** For chat tabs */
|
||||
chatId?: number | null;
|
||||
chatUrl?: string;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
/** For document tabs */
|
||||
documentId?: number;
|
||||
workspaceId?: number;
|
||||
entityId: number | null;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
|
|
@ -27,9 +18,8 @@ interface TabsState {
|
|||
const INITIAL_CHAT_TAB: Tab = {
|
||||
id: "chat-new",
|
||||
type: "chat",
|
||||
title: "New Chat",
|
||||
chatId: null,
|
||||
chatUrl: undefined,
|
||||
entityId: null,
|
||||
workspaceId: 0,
|
||||
};
|
||||
|
||||
const initialState: TabsState = {
|
||||
|
|
@ -37,23 +27,14 @@ const initialState: TabsState = {
|
|||
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
|
||||
// keep tabs open across multiple workspaces (browser-like behavior).
|
||||
const localStorageAdapter = createJSONStorage<TabsState>(
|
||||
() => (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>(
|
||||
"surfsense:tabs",
|
||||
"surfsense:tabs:v2",
|
||||
initialState,
|
||||
localStorageAdapter,
|
||||
{ getOnInit: true }
|
||||
|
|
@ -66,11 +47,11 @@ export const activeTabAtom = atom((get) => {
|
|||
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";
|
||||
}
|
||||
|
||||
function makeDocumentTabId(documentId: number): string {
|
||||
export function makeDocumentTabId(documentId: number): string {
|
||||
return `doc-${documentId}`;
|
||||
}
|
||||
|
||||
|
|
@ -86,24 +67,12 @@ export const syncChatTabAtom = atom(
|
|||
set,
|
||||
{
|
||||
chatId,
|
||||
title,
|
||||
chatUrl,
|
||||
workspaceId,
|
||||
visibility,
|
||||
hasComments,
|
||||
}: {
|
||||
chatId: number | null;
|
||||
title?: string;
|
||||
chatUrl?: string;
|
||||
workspaceId: number;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
) => {
|
||||
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeChatTabId(chatId);
|
||||
const existing = state.tabs.find((t) => t.id === tabId);
|
||||
|
|
@ -116,11 +85,8 @@ export const syncChatTabAtom = atom(
|
|||
t.id === tabId
|
||||
? {
|
||||
...t,
|
||||
title: title || t.title,
|
||||
chatUrl: chatUrl || t.chatUrl,
|
||||
workspaceId: workspaceId ?? t.workspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
entityId: chatId,
|
||||
workspaceId,
|
||||
}
|
||||
: t
|
||||
),
|
||||
|
|
@ -136,11 +102,13 @@ export const syncChatTabAtom = atom(
|
|||
set(tabsStateAtom, {
|
||||
...state,
|
||||
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 {
|
||||
set(tabsStateAtom, {
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }],
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
}
|
||||
|
|
@ -152,12 +120,8 @@ export const syncChatTabAtom = atom(
|
|||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: "chat",
|
||||
title: title || "New Chat",
|
||||
chatId,
|
||||
chatUrl,
|
||||
entityId: chatId,
|
||||
workspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
};
|
||||
|
||||
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(
|
||||
null,
|
||||
(get, set, { chatId, title }: { chatId: number; title: string }) => {
|
||||
(get, set, { chatId }: { chatId: number; title?: string }) => {
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeChatTabId(chatId);
|
||||
const hasExactTab = state.tabs.some((t) => t.id === tabId);
|
||||
|
||||
// During lazy thread creation, title updates can arrive before "chat-new"
|
||||
// is swapped to chat-{id}. In that case, promote the active "chat-new" tab.
|
||||
// During lazy thread creation, title updates can arrive before "chat-new" is
|
||||
// swapped to chat-{id}. In that case, promote the pointer only; title comes
|
||||
// from Zero.
|
||||
if (!hasExactTab && state.activeTabId === "chat-new") {
|
||||
set(tabsStateAtom, {
|
||||
...state,
|
||||
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,
|
||||
set,
|
||||
{ documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string }
|
||||
{ documentId, workspaceId }: { documentId: number; workspaceId: number; title?: string }
|
||||
) => {
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeDocumentTabId(documentId);
|
||||
|
|
@ -214,7 +175,7 @@ export const openDocumentTabAtom = atom(
|
|||
set(tabsStateAtom, {
|
||||
...state,
|
||||
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;
|
||||
}
|
||||
|
|
@ -222,8 +183,7 @@ export const openDocumentTabAtom = atom(
|
|||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: "document",
|
||||
title: title || `Document ${documentId}`,
|
||||
documentId,
|
||||
entityId: documentId,
|
||||
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
|
||||
if (remaining.length === 0) {
|
||||
const closedTab = state.tabs[idx];
|
||||
set(tabsStateAtom, {
|
||||
tabs: [INITIAL_CHAT_TAB],
|
||||
tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
return INITIAL_CHAT_TAB;
|
||||
return { ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId };
|
||||
}
|
||||
|
||||
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);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const deletedChatIds = get(deletedChatIdsAtom);
|
||||
set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
|
||||
|
||||
const remaining = state.tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
// Always keep at least one tab available.
|
||||
if (remaining.length === 0) {
|
||||
const removedTab = state.tabs[idx];
|
||||
set(tabsStateAtom, {
|
||||
tabs: [INITIAL_CHAT_TAB],
|
||||
tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
return INITIAL_CHAT_TAB;
|
||||
return { ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId };
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
/** Reset tabs when switching workspaces. */
|
||||
export const resetTabsAtom = atom(null, (_get, set) => {
|
||||
set(tabsStateAtom, { ...initialState });
|
||||
set(deletedChatIdsAtom, new Set<number>());
|
||||
/** Remove unresolved chat pointers after Zero confirms the queried rows are complete. */
|
||||
export const pruneMissingChatTabsAtom = atom(null, (get, set, missingChatIds: Set<number>) => {
|
||||
if (missingChatIds.size === 0) return;
|
||||
|
||||
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 });
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue