SurfSense/surfsense_web/atoms/tabs/tabs.atom.ts
DESKTOP-RTLN3BA\$punk 64c913baa3 chore: linting
2026-03-27 03:17:05 -07:00

219 lines
5.5 KiB
TypeScript

import { atom } from "jotai";
import { atomWithStorage, createJSONStorage } from "jotai/utils";
export type TabType = "chat" | "document";
export interface Tab {
id: string;
type: TabType;
title: string;
/** For chat tabs */
chatId?: number | null;
chatUrl?: string;
/** For document tabs */
documentId?: number;
searchSpaceId?: number;
}
interface TabsState {
tabs: Tab[];
activeTabId: string | null;
}
const INITIAL_CHAT_TAB: Tab = {
id: "chat-new",
type: "chat",
title: "New Chat",
chatId: null,
chatUrl: undefined,
};
const initialState: TabsState = {
tabs: [INITIAL_CHAT_TAB],
activeTabId: "chat-new",
};
const sessionStorageAdapter = createJSONStorage<TabsState>(
() => (typeof window !== "undefined" ? sessionStorage : undefined) as Storage
);
export const tabsStateAtom = atomWithStorage<TabsState>(
"surfsense:tabs",
initialState,
sessionStorageAdapter,
{ getOnInit: true }
);
export const tabsAtom = atom((get) => get(tabsStateAtom).tabs);
export const activeTabIdAtom = atom((get) => get(tabsStateAtom).activeTabId);
export const activeTabAtom = atom((get) => {
const state = get(tabsStateAtom);
return state.tabs.find((t) => t.id === state.activeTabId) ?? null;
});
function makeChatTabId(chatId: number | null): string {
return chatId ? `chat-${chatId}` : "chat-new";
}
function makeDocumentTabId(documentId: number): string {
return `doc-${documentId}`;
}
/**
* Sync the current chat from Next.js routing into the tab bar.
* If a tab for this chat already exists, activate it.
* Otherwise, replace the "new chat" tab or create one.
*/
export const syncChatTabAtom = atom(
null,
(
get,
set,
{ chatId, title, chatUrl }: { chatId: number | null; title?: string; chatUrl?: string }
) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
const existing = state.tabs.find((t) => t.id === tabId);
if (existing) {
set(tabsStateAtom, {
...state,
activeTabId: tabId,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, title: title || t.title, chatUrl: chatUrl || t.chatUrl } : t
),
});
return;
}
// If navigating to a new chat (no chatId), ensure there's a "new chat" tab
if (!chatId) {
const hasNewChatTab = state.tabs.some((t) => t.id === "chat-new");
if (hasNewChatTab) {
set(tabsStateAtom, { ...state, activeTabId: "chat-new" });
} else {
set(tabsStateAtom, {
tabs: [...state.tabs, INITIAL_CHAT_TAB],
activeTabId: "chat-new",
});
}
return;
}
// Replace the "new chat" tab if it exists and is empty, otherwise add new tab
const newChatTabIdx = state.tabs.findIndex((t) => t.id === "chat-new");
const newTab: Tab = {
id: tabId,
type: "chat",
title: title || `Chat ${chatId}`,
chatId,
chatUrl,
};
let updatedTabs: Tab[];
if (newChatTabIdx !== -1) {
updatedTabs = [...state.tabs];
updatedTabs[newChatTabIdx] = newTab;
} else {
updatedTabs = [...state.tabs, newTab];
}
set(tabsStateAtom, { tabs: updatedTabs, activeTabId: tabId });
}
);
/** Update the title of the current chat tab (e.g., when a chat gets its first response). */
export const updateChatTabTitleAtom = atom(
null,
(get, set, { chatId, title }: { chatId: number; title: string }) => {
const state = get(tabsStateAtom);
const tabId = makeChatTabId(chatId);
set(tabsStateAtom, {
...state,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
});
}
);
/** Open a document tab. If already open, just switch to it. */
export const openDocumentTabAtom = atom(
null,
(
get,
set,
{
documentId,
searchSpaceId,
title,
}: { documentId: number; searchSpaceId: number; title?: string }
) => {
const state = get(tabsStateAtom);
const tabId = makeDocumentTabId(documentId);
const existing = state.tabs.find((t) => t.id === tabId);
if (existing) {
set(tabsStateAtom, {
...state,
activeTabId: tabId,
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title: title || t.title } : t)),
});
return;
}
const newTab: Tab = {
id: tabId,
type: "document",
title: title || `Document ${documentId}`,
documentId,
searchSpaceId,
};
set(tabsStateAtom, {
tabs: [...state.tabs, newTab],
activeTabId: tabId,
});
}
);
/** Switch to a tab by ID. Returns the tab so the caller can navigate if needed. */
export const switchTabAtom = atom(null, (get, set, tabId: string) => {
const state = get(tabsStateAtom);
const tab = state.tabs.find((t) => t.id === tabId);
if (tab) {
set(tabsStateAtom, { ...state, activeTabId: tabId });
}
return tab ?? null;
});
/** Close a tab. If it was active, activate the nearest sibling. */
export const closeTabAtom = atom(null, (get, set, tabId: string) => {
const state = get(tabsStateAtom);
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return null;
const remaining = state.tabs.filter((t) => t.id !== tabId);
// Don't close the last tab — always keep at least one
if (remaining.length === 0) {
set(tabsStateAtom, {
tabs: [INITIAL_CHAT_TAB],
activeTabId: "chat-new",
});
return INITIAL_CHAT_TAB;
}
let newActiveId = state.activeTabId;
if (state.activeTabId === tabId) {
// Activate the tab to the left (or right if first)
const newIdx = Math.min(idx, remaining.length - 1);
newActiveId = remaining[newIdx].id;
}
set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
return remaining.find((t) => t.id === newActiveId) ?? null;
});
/** Reset tabs when switching search spaces. */
export const resetTabsAtom = atom(null, (_get, set) => {
set(tabsStateAtom, { ...initialState });
});