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 { 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 });
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue