diff --git a/surfsense_backend/app/config/__init__.py b/surfsense_backend/app/config/__init__.py index 8e0ac12bc..73062775c 100644 --- a/surfsense_backend/app/config/__init__.py +++ b/surfsense_backend/app/config/__init__.py @@ -861,6 +861,9 @@ class Config: # Auth AUTH_TYPE = os.getenv("AUTH_TYPE", "LOCAL") REGISTRATION_ENABLED = os.getenv("REGISTRATION_ENABLED", "TRUE").upper() == "TRUE" + # Max workspaces a user may own. The frontend reads this through the + # workspace limits route; do not duplicate this value client-side. + MAX_WORKSPACES_PER_USER = int(os.getenv("MAX_WORKSPACES_PER_USER", "400")) # Google OAuth GOOGLE_OAUTH_CLIENT_ID = os.getenv("GOOGLE_OAUTH_CLIENT_ID") diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index 9dd3f196c..53d4344f7 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -6,6 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select from app.auth.context import AuthContext +from app.config import config from app.db import ( Permission, Workspace, @@ -81,6 +82,22 @@ async def create_workspace( user = auth.user try: workspace_data = workspace.model_dump() + not_deleting = ~Workspace.name.startswith("[DELETING] ") + owned_count = ( + await session.execute( + select(func.count()) + .select_from(Workspace) + .filter(Workspace.user_id == user.id, not_deleting) + ) + ).scalar_one() + if owned_count >= config.MAX_WORKSPACES_PER_USER: + raise HTTPException( + status_code=409, + detail=( + "Workspace limit reached. You can own at most " + f"{config.MAX_WORKSPACES_PER_USER} workspaces." + ), + ) # citations_enabled defaults to True (handled by Pydantic schema) # qna_custom_instructions defaults to None/empty (handled by DB) @@ -207,6 +224,11 @@ async def read_workspaces( ) from e +@router.get("/workspaces/limits") +async def read_workspace_limits(_auth: AuthContext = Depends(allow_any_principal)): + return {"max_workspaces_per_user": config.MAX_WORKSPACES_PER_USER} + + @router.get("/workspaces/{workspace_id}", response_model=WorkspaceRead) async def read_workspace( workspace_id: int, diff --git a/surfsense_backend/app/zero_publication.py b/surfsense_backend/app/zero_publication.py index c44a29dcd..ea4d1c90c 100644 --- a/surfsense_backend/app/zero_publication.py +++ b/surfsense_backend/app/zero_publication.py @@ -57,6 +57,9 @@ AUTOMATION_COLS = [ "workspace_id", ] +# Only the columns Zero needs as the authz *parent* of chat messages/comments/ +# session-state (``whereExists("thread")`` + space constraint). Tab titles and +# visibility are resolved over REST (react-query), not pushed via Zero. NEW_CHAT_THREAD_COLS = [ "id", "workspace_id", diff --git a/surfsense_backend/tests/unit/routes/test_workspaces_limits.py b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py new file mode 100644 index 000000000..ab89d08bc --- /dev/null +++ b/surfsense_backend/tests/unit/routes/test_workspaces_limits.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from types import SimpleNamespace + +import pytest +from fastapi import HTTPException + +from app.routes import workspaces_routes +from app.schemas import WorkspaceCreate + +pytestmark = pytest.mark.unit + + +class _CountResult: + def __init__(self, count: int): + self.count = count + + def scalar_one(self) -> int: + return self.count + + +class _FakeSession: + def __init__(self, owned_count: int): + self.owned_count = owned_count + + async def execute(self, _statement): + return _CountResult(self.owned_count) + + +@pytest.mark.asyncio +async def test_read_workspace_limits_uses_backend_config(monkeypatch): + monkeypatch.setattr( + workspaces_routes.config, + "MAX_WORKSPACES_PER_USER", + 37, + raising=False, + ) + + result = await workspaces_routes.read_workspace_limits(_auth=SimpleNamespace()) + + assert result == {"max_workspaces_per_user": 37} + + +@pytest.mark.asyncio +async def test_create_workspace_rejects_when_owned_limit_reached(monkeypatch): + monkeypatch.setattr( + workspaces_routes.config, + "MAX_WORKSPACES_PER_USER", + 2, + raising=False, + ) + auth = SimpleNamespace(user=SimpleNamespace(id="user-1")) + session = _FakeSession(owned_count=2) + + with pytest.raises(HTTPException) as exc_info: + await workspaces_routes.create_workspace( + WorkspaceCreate(name="Extra", description=""), + session=session, + auth=auth, + ) + + assert exc_info.value.status_code == 409 + assert "at most 2 workspaces" in exc_info.value.detail diff --git a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx index 1156be3f6..058e61598 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/automations/components/automations-loading.tsx @@ -13,21 +13,18 @@ export function AutomationsLoadingRows() { return ( <> {ROW_KEYS.map((key) => ( - - -
- - -
+ + + - + - + - - + + ))} diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx index f5b857ce1..dff264c43 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/new-chat/[[...chat_id]]/page.tsx @@ -39,7 +39,6 @@ import { TokenUsageProvider, } from "@/components/assistant-ui/token-usage-context"; import { Button } from "@/components/ui/button"; -import { Skeleton } from "@/components/ui/skeleton"; import { useSyncChatArtifacts } from "@/features/chat-artifacts"; import { type HitlDecision, @@ -104,6 +103,7 @@ const MobileArtifactsPanel = dynamic( /** Stable empty reference so idle threads don't re-render the interrupt provider. */ const EMPTY_PENDING_INTERRUPTS: PendingInterruptState[] = []; +const EMPTY_MESSAGES: ThreadMessageLike[] = []; function parseUrlChatId(id: string | string[] | undefined): number { let parsed = 0; @@ -115,61 +115,6 @@ function parseUrlChatId(id: string | string[] | undefined): number { return Number.isNaN(parsed) ? 0 : parsed; } -function ThreadMessagesSkeleton() { - return ( -
-
-
-
-
- -
- -
- - - -
- -
- -
- -
- - - -
- -
- -
-
- -
-
- -
-
-
-
- ); -} - export default function NewChatPage() { const params = useParams(); const queryClient = useQueryClient(); @@ -226,6 +171,16 @@ export default function NewChatPage() { const threadDetailQuery = useThreadDetail(activeThreadId); const threadMessagesQuery = useThreadMessages(activeThreadId); + const hydratedMessagesRef = useRef<{ + threadId: number | null; + data: typeof threadMessagesQuery.data; + }>({ threadId: null, data: undefined }); + const hasLiveStream = !!streamState && streamState.messages.length > 0; + const isActiveThreadHydrated = hydratedMessagesRef.current.threadId === activeThreadId; + const shouldHideStaleMessages = !!activeThreadId && !hasLiveStream && !isActiveThreadHydrated; + const isThreadMessagesLoading = + shouldHideStaleMessages && !threadMessagesQuery.error; + const runtimeMessages = shouldHideStaleMessages ? EMPTY_MESSAGES : displayMessages; // Live collaboration: sync session state and messages via Zero. Kept on the // page because "AI responding" reflects the currently-viewed thread. @@ -296,8 +251,8 @@ export default function NewChatPage() { // Latest displayed messages, read by the engine wrappers at call time so // history/slice seeds stay fresh without re-creating the callbacks. - const messagesRef = useRef(displayMessages); - messagesRef.current = displayMessages; + const messagesRef = useRef(runtimeMessages); + messagesRef.current = runtimeMessages; const buildCtx = useCallback( (): EngineContext => ({ @@ -309,11 +264,6 @@ export default function NewChatPage() { [workspaceId, activeThreadId] ); - const hydratedMessagesRef = useRef<{ - threadId: number | null; - data: typeof threadMessagesQuery.data; - }>({ threadId: null, data: undefined }); - // Reset thread-local runtime state on route/workspace changes. The durable // streaming overlay is preserved for any still-running thread (and the newly // viewed thread) via ``clearInactive`` so an in-flight turn survives nav. @@ -349,11 +299,7 @@ export default function NewChatPage() { setCurrentThread(thread); syncChatTab({ chatId: thread.id, - title: thread.title, - chatUrl: `/dashboard/${thread.workspace_id ?? workspaceId}/new-chat/${thread.id}`, workspaceId: thread.workspace_id ?? workspaceId, - visibility: thread.visibility, - hasComments: thread.has_comments ?? false, }); } }, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]); @@ -517,8 +463,11 @@ export default function NewChatPage() { // Handle new message from user const onNew = useCallback( - (message: AppendMessage) => startNewChat(buildCtx(), message), - [buildCtx] + (message: AppendMessage) => { + if (isThreadMessagesLoading) return Promise.resolve(); + return startNewChat(buildCtx(), message); + }, + [buildCtx, isThreadMessagesLoading] ); // Cancel the in-flight turn (targets the active stream's owner thread). @@ -747,11 +696,11 @@ export default function NewChatPage() { }, [buildCtx, pendingInterrupts, activeThreadId]); // Surface the thread's deliverables to the layout-level artifacts sidebar. - useSyncChatArtifacts(displayMessages); + useSyncChatArtifacts(runtimeMessages); // Create external store runtime const runtime = useExternalStoreRuntime({ - messages: displayMessages, + messages: runtimeMessages, isRunning, onNew, onEdit, @@ -764,12 +713,7 @@ export default function NewChatPage() { ? (threadDetailQuery.error ?? threadMessagesQuery.error) : null; const shouldShowThreadLoadError = - !!threadLoadError && !!activeThreadId && !currentThread && displayMessages.length === 0; - const isThreadMessagesLoading = - !!activeThreadId && - threadMessagesQuery.isPending && - displayMessages.length === 0 && - !threadMessagesQuery.error; + !!threadLoadError && !!activeThreadId && !currentThread && runtimeMessages.length === 0; if (shouldShowThreadLoadError) { return ( @@ -798,12 +742,10 @@ export default function NewChatPage() { >
- - {isThreadMessagesLoading ? ( -
- -
- ) : null} +
diff --git a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx b/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx deleted file mode 100644 index 108671662..000000000 --- a/surfsense_web/app/dashboard/[workspace_id]/new-chat/loading.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { Skeleton } from "@/components/ui/skeleton"; - -export default function Loading() { - return ( -
-
-
-
- {/* User message */} -
- -
- - {/* Assistant message */} -
- - - -
- - {/* User message */} -
- -
- - {/* Assistant message */} -
- - - -
- - {/* User message */} -
- -
-
- - {/* Input bar */} -
-
- -
-
-
-
- ); -} diff --git a/surfsense_web/atoms/tabs/migrate-tabs.test.ts b/surfsense_web/atoms/tabs/migrate-tabs.test.ts deleted file mode 100644 index e0067a4d2..000000000 --- a/surfsense_web/atoms/tabs/migrate-tabs.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { migrateLegacyTabs } from "./migrate-tabs"; - -// Run with: pnpm exec tsx --test atoms/tabs/migrate-tabs.test.ts -test("maps legacy searchSpaceId to workspaceId on read", () => { - const migrated = migrateLegacyTabs({ - tabs: [{ id: "chat-new", type: "chat", searchSpaceId: 7 } as never], - activeTabId: "chat-new", - }); - const tab = migrated.tabs[0] as { workspaceId?: number }; - assert.equal(tab.workspaceId, 7); -}); - -test("leaves an already-migrated workspaceId untouched", () => { - const migrated = migrateLegacyTabs({ - tabs: [{ id: "d1", type: "document", workspaceId: 3, searchSpaceId: 9 } as never], - }); - const tab = migrated.tabs[0] as { workspaceId?: number }; - assert.equal(tab.workspaceId, 3); -}); diff --git a/surfsense_web/atoms/tabs/migrate-tabs.ts b/surfsense_web/atoms/tabs/migrate-tabs.ts deleted file mode 100644 index e95a921de..000000000 --- a/surfsense_web/atoms/tabs/migrate-tabs.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * One-time read-migration for persisted tabs: legacy state stored the workspace - * as `searchSpaceId`. Map it to `workspaceId` on read so already-open tabs keep - * their workspace association after the rename. Pure + dependency-free so it can - * be unit-checked without loading the atom module. - */ -export function migrateLegacyTabs }>( - state: T -): T { - return { - ...state, - tabs: state.tabs.map((t) => { - const legacy = t as { workspaceId?: number; searchSpaceId?: number }; - return legacy.workspaceId === undefined && legacy.searchSpaceId !== undefined - ? { ...t, workspaceId: legacy.searchSpaceId } - : t; - }), - }; -} diff --git a/surfsense_web/atoms/tabs/tabs.atom.ts b/surfsense_web/atoms/tabs/tabs.atom.ts index 45e0c098c..fbc546c42 100644 --- a/surfsense_web/atoms/tabs/tabs.atom.ts +++ b/surfsense_web/atoms/tabs/tabs.atom.ts @@ -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>(new Set()); - // 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( () => (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( - "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()); +/** Remove unresolved chat pointers after Zero confirms the queried rows are complete. */ +export const pruneMissingChatTabsAtom = atom(null, (get, set, missingChatIds: Set) => { + 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 }); }); diff --git a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts index 85203cc1d..09e2aa290 100644 --- a/surfsense_web/atoms/workspaces/workspace-query.atoms.ts +++ b/surfsense_web/atoms/workspaces/workspace-query.atoms.ts @@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys"; export const activeWorkspaceIdAtom = atom(null); -export const workspacesQueryParamsAtom = atom({ - skip: 0, - limit: 10, - owned_only: false, +export const workspaceLimitsAtom = atomWithQuery(() => { + return { + queryKey: cacheKeys.workspaces.limits, + staleTime: Infinity, + queryFn: async () => { + return workspacesApiService.getWorkspaceLimits(); + }, + }; }); export const workspacesAtom = atomWithQuery((get) => { - const queryParams = get(workspacesQueryParamsAtom); + const workspaceLimits = get(workspaceLimitsAtom).data; + const queryParams: GetWorkspacesRequest["queryParams"] = { + skip: 0, + ...(workspaceLimits ? { limit: workspaceLimits.max_workspaces_per_user } : {}), + owned_only: false, + }; return { queryKey: cacheKeys.workspaces.withQueryParams(queryParams), diff --git a/surfsense_web/components/assistant-ui/chat-viewport.tsx b/surfsense_web/components/assistant-ui/chat-viewport.tsx index 83308b642..cb0c57442 100644 --- a/surfsense_web/components/assistant-ui/chat-viewport.tsx +++ b/surfsense_web/components/assistant-ui/chat-viewport.tsx @@ -22,9 +22,19 @@ const ChatScrollToBottom: FC = () => ( export interface ChatViewportProps { children: ReactNode; footer?: ReactNode; + /** + * Keep the footer (composer) pinned even when the thread has no messages — + * needed while an existing thread's messages are still loading, so the + * bottom composer stays visible above the loading skeleton. + */ + footerAlwaysVisible?: boolean; } -export const ChatViewport: FC = ({ children, footer }) => ( +export const ChatViewport: FC = ({ + children, + footer, + footerAlwaysVisible = false, +}) => ( = ({ children, footer }) => ( /> {children} {footer ? ( - !thread.isEmpty}> + footerAlwaysVisible || !thread.isEmpty}> = ({ chunkId }) => { Citation
- +
diff --git a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx index ee2d1c873..f1b8554e7 100644 --- a/surfsense_web/components/assistant-ui/inline-mention-editor.tsx +++ b/surfsense_web/components/assistant-ui/inline-mention-editor.tsx @@ -733,15 +733,9 @@ export const InlineMentionEditor = forwardRef ({ placeholder, - onPaste: (e: React.ClipboardEvent) => { - e.preventDefault(); - const text = e.clipboardData.getData("text/plain"); - const tf = editor.tf as { insertText: (value: string) => void }; - tf.insertText(text); - }, onKeyDown: handleKeyDown, }), - [editor, handleKeyDown, placeholder] + [handleKeyDown, placeholder] ); const mentionEditorContextValue = useMemo( diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 145ac72c5..73d31e11d 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -102,6 +102,7 @@ import { useCommentsSync } from "@/hooks/use-comments-sync"; import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities"; +import { canSubmitChat } from "@/lib/chat/can-submit-chat"; import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; import { slideoutOpenedTickAtom } from "@/lib/layout-events"; @@ -147,13 +148,19 @@ function getComposerSuggestionAnchorPoint( interface ThreadProps { hasActiveThread?: boolean; + isLoadingMessages?: boolean; } -export const Thread: FC = ({ hasActiveThread = false }) => { - return ; +export const Thread: FC = ({ hasActiveThread = false, isLoadingMessages = false }) => { + return ( + + ); }; -const ThreadContent: FC = ({ hasActiveThread = false }) => { +const ThreadContent: FC = ({ + hasActiveThread = false, + isLoadingMessages = false, +}) => { return ( = ({ hasActiveThread = false }) => { }} > hasActiveThread || !thread.isEmpty}> + <> - -
+ + } > !hasActiveThread && thread.isEmpty}> + {isLoadingMessages ? : null} + = ({ hasActiveThread = false }) => { ); }; +const ThreadMessagesSkeletonBody: FC = () => { + return ( +
+
+ +
+ +
+ + + +
+ +
+ +
+ +
+ + + +
+ +
+ +
+
+ ); +}; + const PremiumQuotaPinnedAlert: FC = () => { const currentThreadState = useAtomValue(currentThreadAtom); const alertsByThread = useAtomValue(premiumAlertByThreadAtom); @@ -490,7 +530,15 @@ const ChatUnavailableNotice: FC<{ workspaceId: number; canConfigure: boolean }> ); }; -const Composer: FC = () => { +interface ComposerProps { + hasActiveThread?: boolean; + isLoadingMessages?: boolean; +} + +const Composer: FC = ({ + hasActiveThread = false, + isLoadingMessages = false, +}) => { const [mentionedDocuments, setMentionedDocuments] = useAtom(mentionedDocumentsAtom); const setSubmittedMentions = useSetAtom(submittedMentionsAtom); const [showDocumentPopover, setShowDocumentPopover] = useState(false); @@ -823,7 +871,7 @@ const Composer: FC = () => { ); const handleSubmit = useCallback(() => { - if (isThreadRunning || isBlockedByOtherUser) return; + if (isLoadingMessages || isThreadRunning || isBlockedByOtherUser) return; if (showDocumentPopover || showPromptPicker) return; if (clipboardInitialText) { @@ -844,6 +892,7 @@ const Composer: FC = () => { }, [ showDocumentPopover, showPromptPicker, + isLoadingMessages, isThreadRunning, isBlockedByOtherUser, clipboardInitialText, @@ -1016,15 +1065,17 @@ const Composer: FC = () => {
- {isThreadEmpty && isComposerInputEmpty ? ( + {!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
@@ -1087,12 +1138,16 @@ const ConnectedScraperIcons: FC<{ workspaceId: number }> = ({ workspaceId }) => interface ComposerActionProps { isBlockedByOtherUser?: boolean; + isLoadingMessages?: boolean; + isThreadRunning?: boolean; workspaceId: number; onChatModelSelected?: () => void; } const ComposerAction: FC = ({ isBlockedByOtherUser = false, + isLoadingMessages = false, + isThreadRunning = false, workspaceId, onChatModelSelected, }) => { @@ -1210,7 +1265,20 @@ const ComposerAction: FC = ({ // send that lacks a resolvable model, making this defense-in-depth. const isWorkspaceChatReady = setupStatus?.status === "ready"; - const isSendDisabled = isComposerEmpty || !isWorkspaceChatReady || isBlockedByOtherUser; + const isSendDisabled = !canSubmitChat({ + isLoadingMessages, + isThreadRunning, + isBlockedByOtherUser, + isComposerEmpty, + isWorkspaceChatReady, + }); + const sendTooltip = isLoadingMessages + ? "Loading conversation..." + : isBlockedByOtherUser + ? "Wait for AI to finish responding" + : isComposerEmpty + ? "Enter a message or add a screenshot to send" + : "Send message"; return (
@@ -1652,22 +1720,16 @@ const ComposerAction: FC = ({ )}
-
+
!thread.isRunning}> void; - showHeader?: boolean; } /** @@ -25,11 +25,7 @@ interface CitationPanelContentProps { * with the cited one visually highlighted and auto-scrolled into view. * The user can jump to the full document via the editor panel. */ -export const CitationPanelContent: FC = ({ - chunkId, - onClose, - showHeader = true, -}) => { +export const CitationPanelContent: FC = ({ chunkId, onClose }) => { const openEditorPanel = useSetAtom(openEditorPanelAtom); const chunkWindow = DEFAULT_CHUNK_WINDOW; @@ -85,32 +81,13 @@ export const CitationPanelContent: FC = ({ return ( <>
- {showHeader && ( -
-

Citation

-
- {onClose && ( - - )} -
-
- )} -
+

{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}

-
- {totalChunks > 0 && {totalChunks} chunks} +
{!isLoading && !error && data && ( )} + {onClose && ( + <> + + + + )}
diff --git a/surfsense_web/components/editor-panel/editor-panel.tsx b/surfsense_web/components/editor-panel/editor-panel.tsx index 4f2f598d8..54b964a41 100644 --- a/surfsense_web/components/editor-panel/editor-panel.tsx +++ b/surfsense_web/components/editor-panel/editor-panel.tsx @@ -4,7 +4,6 @@ import { useAtomValue, useSetAtom } from "jotai"; import { Check, Copy, - Download, FileQuestionMark, FileText, Pencil, @@ -163,7 +162,6 @@ export function EditorPanelContent({ const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [saving, setSaving] = useState(false); - const [downloading, setDownloading] = useState(false); const [isEditing, setIsEditing] = useState(false); const [memoryLimits, setMemoryLimits] = useState(null); @@ -514,62 +512,6 @@ export function EditorPanelContent({ setIsEditing(false); }, [editorDoc?.source_markdown]); - const handleDownloadMarkdown = useCallback(async () => { - if (!workspaceId || !documentId) return; - setDownloading(true); - try { - const response = await authenticatedFetch( - buildBackendUrl( - `/api/v1/workspaces/${workspaceId}/documents/${documentId}/download-markdown` - ), - { method: "GET" } - ); - if (!response.ok) throw new Error("Download failed"); - const blob = await response.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - const disposition = response.headers.get("content-disposition"); - const match = disposition?.match(/filename="(.+)"/); - a.download = match?.[1] ?? `${editorDoc?.title || "document"}.md`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - toast.success("Download started"); - } catch { - toast.error("Failed to download document"); - } finally { - setDownloading(false); - } - }, [documentId, editorDoc?.title, workspaceId]); - - const largeDocAlert = viewerMode === "monaco" && !isLocalFileMode && editorDoc && ( - - - - - This document is too large for the editor ( - {formatBytes(editorDoc.content_size_bytes ?? 0)}, {docLineCount.toLocaleString()} lines,{" "} - {editorDoc.chunk_count ?? 0} chunks). Showing raw markdown below. - - - - - ); - return ( <> {showDesktopHeader ? ( @@ -807,7 +749,6 @@ export function EditorPanelContent({ ) : viewerMode === "monaco" && !isLocalFileMode ? ( // Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
- {largeDocAlert}
router.push("/register")} pageUsage={pageUsage} isChatPage + showTabs={false} isLoadingChats={false} > {children} diff --git a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx index 7fc862e49..511c095e5 100644 --- a/surfsense_web/components/layout/providers/LayoutDataProvider.tsx +++ b/surfsense_web/components/layout/providers/LayoutDataProvider.tsx @@ -11,10 +11,10 @@ import { toast } from "sonner"; import { currentThreadAtom, resetCurrentThreadAtom } from "@/atoms/chat/current-thread.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { announcementsDialogAtom } from "@/atoms/layout/dialogs.atom"; -import { removeChatTabAtom, syncChatTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; +import { removeChatTabAtom, syncChatTabAtom } from "@/atoms/tabs/tabs.atom"; import { currentUserAtom } from "@/atoms/user/user-query.atoms"; import { deleteWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; -import { workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; +import { workspaceLimitsAtom, workspacesAtom } from "@/atoms/workspaces/workspace-query.atoms"; import { ActionLogDialog } from "@/components/agent-action-log/action-log-dialog"; import { AnnouncementSpotlight } from "@/components/announcements/AnnouncementSpotlight"; import { AnnouncementsDialog } from "@/components/announcements/AnnouncementsDialog"; @@ -43,6 +43,7 @@ import { Spinner } from "@/components/ui/spinner"; import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useAnnouncements } from "@/hooks/use-announcements"; import { useInbox } from "@/hooks/use-inbox"; +import { getChatUrl, type ResolvedTab } from "@/hooks/use-resolved-tabs"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { notificationsApiService } from "@/lib/apis/notifications-api.service"; import { workspacesApiService } from "@/lib/apis/workspaces-api.service"; @@ -84,6 +85,7 @@ export function LayoutDataProvider({ refetch: refetchWorkspaces, isSuccess: workspacesLoaded, } = useAtomValue(workspacesAtom); + const { data: workspaceLimits } = useAtomValue(workspaceLimitsAtom); const { mutateAsync: deleteWorkspace } = useAtomValue(deleteWorkspaceMutationAtom); const currentThreadState = useAtomValue(currentThreadAtom); const resetCurrentThread = useSetAtom(resetCurrentThreadAtom); @@ -225,6 +227,13 @@ export function LayoutDataProvider({ createdAt: space.created_at, })); }, [workspacesData]); + const maxWorkspacesPerUser = workspaceLimits?.max_workspaces_per_user; + const ownedWorkspaceCount = workspaces.reduce( + (count, space) => count + (space.isOwner ? 1 : 0), + 0 + ); + const isAtWorkspaceLimit = + maxWorkspacesPerUser !== undefined && ownedWorkspaceCount >= maxWorkspacesPerUser; // Find active workspace from list, falling back to the route-scoped detail query. const activeWorkspace: Workspace | null = useMemo(() => { @@ -265,26 +274,18 @@ export function LayoutDataProvider({ // Sync current chat route with tab state useEffect(() => { const chatId = currentChatId ?? null; - const chatUrl = chatId - ? `/dashboard/${workspaceId}/new-chat/${chatId}` - : `/dashboard/${workspaceId}/new-chat`; - const thread = threadsData?.threads?.find((t) => t.id === chatId); syncChatTab({ chatId, - // Avoid overwriting live SSE-updated tab titles with fallback values. - title: chatId ? (thread?.title ?? undefined) : "New Chat", - chatUrl, workspaceId: Number(workspaceId), - ...(thread?.visibility !== undefined ? { visibility: thread.visibility } : {}), }); - }, [currentChatId, workspaceId, threadsData?.threads, syncChatTab]); + }, [currentChatId, workspaceId, syncChatTab]); const chats = useMemo(() => { if (!threadsData?.threads) return []; return threadsData.threads.map((thread) => ({ id: thread.id, - name: thread.title || `Chat ${thread.id}`, + name: thread.title || "New Chat", url: `/dashboard/${workspaceId}/new-chat/${thread.id}`, visibility: thread.visibility, isOwnThread: thread.is_own_thread, @@ -335,8 +336,14 @@ export function LayoutDataProvider({ ); const handleAddWorkspace = useCallback(() => { + if (isAtWorkspaceLimit) { + toast.error( + `Workspace limit reached. You can own at most ${maxWorkspacesPerUser} workspaces.` + ); + return; + } setIsCreateWorkspaceDialogOpen(true); - }, []); + }, [isAtWorkspaceLimit, maxWorkspacesPerUser]); const setAnnouncementsDialog = useSetAtom(announcementsDialogAtom); @@ -426,26 +433,24 @@ export function LayoutDataProvider({ }, [workspaceToLeave, refetchWorkspaces, workspaceId, router, t]); const handleTabSwitch = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.type === "chat") { activateChatThread({ - id: tab.chatId ?? null, - title: tab.title, + id: tab.entityId, url: tab.chatUrl, - workspaceId: tab.workspaceId ?? workspaceId, + workspaceId: tab.workspaceId, ...(tab.visibility !== undefined ? { visibility: tab.visibility } : {}), - ...(tab.hasComments !== undefined ? { hasComments: tab.hasComments } : {}), }); } // Document tabs are handled in-place by LayoutShell — no navigation needed }, - [activateChatThread, workspaceId] + [activateChatThread] ); const handleTabPrefetch = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.type === "chat") { - prefetchChatThread(tab.chatId); + prefetchChatThread(tab.entityId); } }, [prefetchChatThread] @@ -559,16 +564,12 @@ export function LayoutDataProvider({ const fallbackTab = removeChatTab(chatToDelete.id); if (currentChatId === chatToDelete.id) { resetCurrentThread(); - if (fallbackTab?.type === "chat" && fallbackTab.chatUrl) { + if (fallbackTab?.type === "chat") { + const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId); activateChatThread({ - id: fallbackTab.chatId ?? null, - title: fallbackTab.title, - url: fallbackTab.chatUrl, - workspaceId: fallbackTab.workspaceId ?? workspaceId, - ...(fallbackTab.visibility !== undefined ? { visibility: fallbackTab.visibility } : {}), - ...(fallbackTab.hasComments !== undefined - ? { hasComments: fallbackTab.hasComments } - : {}), + id: fallbackTab.entityId, + url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId), + workspaceId: fallbackWorkspaceId, }); } else { const isOutOfSync = currentThreadState.id !== null && !params?.chat_id; @@ -631,6 +632,9 @@ export function LayoutDataProvider({ const isArtifactsPage = pathname?.endsWith("/artifacts") === true; const isPlaygroundPage = pathname?.includes("/playground") === true; const isAllChatsPage = pathname?.endsWith("/chats") === true; + const handleChatsClick = useCallback(() => { + router.push(`/dashboard/${workspaceId}/chats`); + }, [router, workspaceId]); const handleViewAllChats = useCallback(() => { router.push( isAllChatsPage ? `/dashboard/${workspaceId}/new-chat` : `/dashboard/${workspaceId}/chats` @@ -660,6 +664,8 @@ export function LayoutDataProvider({ onWorkspaceDelete={handleWorkspaceDeleteClick} onWorkspaceSettings={handleWorkspaceSettings} onAddWorkspace={handleAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} workspace={activeWorkspace} navItems={navItems} onNavItemClick={handleNavItemClick} @@ -671,6 +677,7 @@ export function LayoutDataProvider({ onChatRename={handleChatRename} onChatDelete={handleChatDelete} onChatArchive={handleChatArchive} + onChatsClick={handleChatsClick} onViewAllChats={handleViewAllChats} user={{ email: user?.email || "", diff --git a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx index 214e95a93..68b4d84e9 100644 --- a/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx +++ b/surfsense_web/components/layout/ui/dialogs/CreateWorkspaceDialog.tsx @@ -6,6 +6,7 @@ import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { useState } from "react"; import { useForm } from "react-hook-form"; +import { toast } from "sonner"; import * as z from "zod"; import { createWorkspaceMutationAtom } from "@/atoms/workspaces/workspace-mutation.atoms"; import { Button } from "@/components/ui/button"; @@ -85,6 +86,7 @@ export function CreateWorkspaceDialog({ open, onOpenChange }: CreateWorkspaceDia ); } catch (error) { console.error("Failed to create workspace:", error); + toast.error(error instanceof Error ? error.message : "Failed to create workspace"); setIsSubmitting(false); } }; diff --git a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx index 1f700c89f..0eeac9ca7 100644 --- a/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx +++ b/surfsense_web/components/layout/ui/icon-rail/IconRail.tsx @@ -20,6 +20,8 @@ interface IconRailProps { onWorkspaceDelete?: (workspace: Workspace) => void; onWorkspaceSettings?: (workspace: Workspace) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; isSingleRailMode?: boolean; onNewChat?: () => void; navItems?: NavItem[]; @@ -42,6 +44,8 @@ export function IconRail({ onWorkspaceDelete, onWorkspaceSettings, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, isSingleRailMode = false, onNewChat, navItems = [], @@ -78,6 +82,10 @@ export function IconRail({ })), ] : []; + const addWorkspaceLabel = + isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined + ? `Workspace limit reached: ${maxWorkspacesPerUser}` + : "Add workspace"; return (
@@ -99,18 +107,21 @@ export function IconRail({ - + + + - Add workspace + {addWorkspaceLabel} diff --git a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx index 7a42d5a2e..42ef141b2 100644 --- a/surfsense_web/components/layout/ui/shell/LayoutShell.tsx +++ b/surfsense_web/components/layout/ui/shell/LayoutShell.tsx @@ -3,10 +3,11 @@ import { useAtomValue } from "jotai"; import dynamic from "next/dynamic"; import { useMemo, useState } from "react"; -import { activeTabAtom, type Tab } from "@/atoms/tabs/tabs.atom"; +import { activeTabIdAtom } from "@/atoms/tabs/tabs.atom"; import { Logo } from "@/components/Logo"; import { Spinner } from "@/components/ui/spinner"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs"; import { useIsMobile } from "@/hooks/use-mobile"; import { useElectronAPI } from "@/hooks/use-platform"; import { cn } from "@/lib/utils"; @@ -44,6 +45,20 @@ const DocumentTabContent = dynamic( const PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE = "surfsense_playground_sidebar_collapsed"; const PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 365; +function persistPlaygroundSidebarCollapsedCookie(isCollapsed: boolean) { + void window.cookieStore + ?.set({ + name: PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE, + value: String(isCollapsed), + path: "/", + expires: Date.now() + PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE * 1000, + sameSite: "lax", + }) + .catch(() => { + // Ignore preference persistence failures. + }); +} + function MacDesktopTitleBar({ isSidebarCollapsed, onToggleSidebar, @@ -81,6 +96,8 @@ interface LayoutShellProps { onWorkspaceDelete?: (workspace: Workspace) => void; onWorkspaceSettings?: (workspace: Workspace) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; workspace: Workspace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; @@ -92,6 +109,7 @@ interface LayoutShellProps { onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; + onChatsClick?: () => void; onViewAllChats?: () => void; user: User; onSettings?: () => void; @@ -106,6 +124,7 @@ interface LayoutShellProps { defaultCollapsed?: boolean; isChatPage?: boolean; isAllChatsPage?: boolean; + showTabs?: boolean; useWorkspacePanel?: boolean; workspacePanelViewportClassName?: string; workspacePanelContentClassName?: string; @@ -113,8 +132,8 @@ interface LayoutShellProps { className?: string; notifications?: NotificationsDropdownData; isLoadingChats?: boolean; - onTabSwitch?: (tab: Tab) => void; - onTabPrefetch?: (tab: Tab) => void; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; playgroundSidebar?: React.ReactNode; initialPlaygroundSidebarCollapsed?: boolean; } @@ -124,19 +143,85 @@ function MainContentPanel({ onTabSwitch, onTabPrefetch, onNewChat, + showTabs = true, showRightPanelExpandButton = true, showTopBorder = false, children, }: { isChatPage: boolean; - onTabSwitch?: (tab: Tab) => void; - onTabPrefetch?: (tab: Tab) => void; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; onNewChat?: () => void; + showTabs?: boolean; showRightPanelExpandButton?: boolean; showTopBorder?: boolean; children: React.ReactNode; }) { - const activeTab = useAtomValue(activeTabAtom); + if (!showTabs) { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +} + +function UntabbedMainContentPanel({ + isChatPage, + showTopBorder, + children, +}: { + isChatPage: boolean; + showTopBorder: boolean; + children: React.ReactNode; +}) { + return ( +
+
+
+
+ {children} +
+
+
+ ); +} + +function TabbedMainContentPanel({ + isChatPage, + onTabSwitch, + onTabPrefetch, + onNewChat, + showRightPanelExpandButton, + showTopBorder, + children, +}: { + isChatPage: boolean; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; + onNewChat?: () => void; + showRightPanelExpandButton: boolean; + showTopBorder: boolean; + children: React.ReactNode; +}) { + const activeTabId = useAtomValue(activeTabIdAtom); + const tabs = useResolvedTabs(); + const activeTab = tabs.find((tab) => tab.id === activeTabId) ?? null; const isDocumentTab = activeTab?.type === "document"; return ( @@ -153,11 +238,11 @@ function MainContentPanel({
- {isDocumentTab && activeTab.documentId && activeTab.workspaceId ? ( + {isDocumentTab && activeTab.entityId && activeTab.workspaceId ? (
@@ -183,6 +268,8 @@ export function LayoutShell({ onWorkspaceDelete, onWorkspaceSettings, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, workspace, navItems, onNavItemClick, @@ -194,6 +281,7 @@ export function LayoutShell({ onChatRename, onChatDelete, onChatArchive, + onChatsClick, onViewAllChats, user, onSettings, @@ -208,6 +296,7 @@ export function LayoutShell({ defaultCollapsed = false, isChatPage = false, isAllChatsPage = false, + showTabs = true, useWorkspacePanel = false, workspacePanelViewportClassName, workspacePanelContentClassName, @@ -242,8 +331,7 @@ export function LayoutShell({ const handlePlaygroundSidebarToggle = () => { setIsPlaygroundSidebarCollapsed((collapsed) => { const nextCollapsed = !collapsed; - const secureAttribute = window.location.protocol === "https:" ? "; Secure" : ""; - document.cookie = `${PLAYGROUND_SIDEBAR_COLLAPSED_COOKIE}=${nextCollapsed}; Path=/; Max-Age=${PLAYGROUND_SIDEBAR_COOKIE_MAX_AGE}; SameSite=Lax${secureAttribute}`; + persistPlaygroundSidebarCollapsedCookie(nextCollapsed); return nextCollapsed; }); }; @@ -265,6 +353,8 @@ export function LayoutShell({ activeWorkspaceId={activeWorkspaceId} onWorkspaceSelect={onWorkspaceSelect} onAddWorkspace={onAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} workspace={workspace} navItems={navItems} onNavItemClick={onNavItemClick} @@ -276,6 +366,7 @@ export function LayoutShell({ onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} + onChatsClick={onChatsClick} onViewAllChats={onViewAllChats} isAllChatsActive={isAllChatsPage} user={user} @@ -336,6 +427,8 @@ export function LayoutShell({ onWorkspaceDelete={onWorkspaceDelete} onWorkspaceSettings={onWorkspaceSettings} onAddWorkspace={onAddWorkspace} + isAtWorkspaceLimit={isAtWorkspaceLimit} + maxWorkspacesPerUser={maxWorkspacesPerUser} isSingleRailMode={false} user={user} onUserSettings={onUserSettings} @@ -375,6 +468,7 @@ export function LayoutShell({ onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} + onChatsClick={onChatsClick} onViewAllChats={onViewAllChats} isAllChatsActive={isAllChatsPage} user={user} @@ -451,6 +545,7 @@ export function LayoutShell({ onTabSwitch={onTabSwitch} onTabPrefetch={onTabPrefetch} onNewChat={onNewChat} + showTabs={showTabs} showRightPanelExpandButton={!isMacDesktop} showTopBorder={isMacDesktop} > diff --git a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx index 3063a9c50..bc861f443 100644 --- a/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/AllChatsSidebar.tsx @@ -37,14 +37,14 @@ import { import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; import { Spinner } from "@/components/ui/spinner"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { useActivateChatThread } from "@/hooks/use-activate-chat-thread"; import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { useLongPress } from "@/hooks/use-long-press"; import { useIsMobile } from "@/hooks/use-mobile"; +import { getChatUrl } from "@/hooks/use-resolved-tabs"; import { useArchiveThread, useDeleteThread, useRenameThread } from "@/hooks/use-thread-mutations"; import { fetchThreads, searchThreads, type ThreadListItem } from "@/lib/chat/thread-persistence"; -import { formatThreadTimestamp } from "@/lib/format-date"; +import { formatRelativeDate } from "@/lib/format-date"; import { cn } from "@/lib/utils"; interface AllChatsContentProps { @@ -94,7 +94,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { const { data: threadsData, error: threadsError, + isFetching: isFetchingThreads, isLoading: isLoadingThreads, + isPlaceholderData, } = useQuery({ queryKey: ["all-threads", workspaceId], queryFn: () => fetchThreads(Number(workspaceId)), @@ -144,22 +146,12 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { if (currentChatId === threadId) { setTimeout(() => { - if ( - fallbackTab?.type === "chat" && - fallbackTab.chatUrl && - fallbackTab.chatId !== undefined - ) { + if (fallbackTab?.type === "chat") { + const fallbackWorkspaceId = fallbackTab.workspaceId || Number(workspaceId); activateChatThread({ - id: fallbackTab.chatId ?? null, - title: fallbackTab.title, - url: fallbackTab.chatUrl, - workspaceId: fallbackTab.workspaceId ?? workspaceId, - ...(fallbackTab.visibility !== undefined - ? { visibility: fallbackTab.visibility } - : {}), - ...(fallbackTab.hasComments !== undefined - ? { hasComments: fallbackTab.hasComments } - : {}), + id: fallbackTab.entityId, + url: getChatUrl(fallbackWorkspaceId, fallbackTab.entityId), + workspaceId: fallbackWorkspaceId, }); return; } @@ -228,6 +220,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { }, []); const isLoading = isSearchMode ? isLoadingSearch : isLoadingThreads; + const isLoadingMoreThreads = !isSearchMode && isFetchingThreads && isPlaceholderData; const error = isSearchMode ? searchError : threadsError; const selectedFilterLabel = showArchived ? "Archived" : "Active"; @@ -285,7 +278,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { placeholder={t("search_chats") || "Search chats..."} value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - className="h-12 border-0 bg-muted pl-10 pr-9 text-base shadow-none" + className="h-10 border-0 bg-muted pl-10 pr-9 text-base shadow-none" /> {searchQuery && (
@@ -355,7 +350,7 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { "h-auto w-full justify-start gap-2.5 overflow-hidden px-3 py-2.5 text-left text-base font-normal", "group-hover/item:bg-accent group-hover/item:text-accent-foreground", "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring", - thread.visibility === "SEARCH_SPACE" && "pr-16", + thread.visibility === "SEARCH_SPACE" ? "pr-44" : "pr-36", isActive && "bg-accent text-accent-foreground", isBusy && "opacity-50 pointer-events-none" )} @@ -363,35 +358,24 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { {thread.title || "New Chat"} ) : ( - - - - - -

- {t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)} -

-
-
+ )}
-
- {thread.visibility === "SEARCH_SPACE" ? ( - - Shared - - ) : null} +
+
+ {thread.visibility === "SEARCH_SPACE" ? ( + + Shared + + ) : null} + + {formatRelativeDate(thread.updatedAt)} + +
setOpenDropdownId(isOpen ? thread.id : null)} @@ -431,11 +417,9 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) { variant="ghost" size="icon" className={cn( - "pointer-events-auto h-7 w-7 hover:bg-transparent", + "pointer-events-auto absolute right-0 h-7 w-7 hover:bg-transparent", openDropdownId === thread.id && "bg-accent hover:bg-accent", - !isMobile && - openDropdownId !== thread.id && - "opacity-0 group-hover/item:opacity-100" + openDropdownId !== thread.id && "opacity-0 group-hover/item:opacity-100" )} disabled={isBusy} > @@ -485,6 +469,21 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
); })} + {isLoadingMoreThreads ? ( +
+ {[62, 48, 56].map((titleWidth) => ( +
+ +
+ ))} +
+ ) : null}
) : isSearchMode ? (
diff --git a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx index 1d02b56f4..c84d39891 100644 --- a/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/MobileSidebar.tsx @@ -17,6 +17,8 @@ interface MobileSidebarProps { activeWorkspaceId: number | null; onWorkspaceSelect: (id: number) => void; onAddWorkspace: () => void; + isAtWorkspaceLimit?: boolean; + maxWorkspacesPerUser?: number; workspace: Workspace | null; navItems: NavItem[]; onNavItemClick?: (item: NavItem) => void; @@ -28,6 +30,7 @@ interface MobileSidebarProps { onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; + onChatsClick?: () => void; onViewAllChats?: () => void; isAllChatsActive?: boolean; user: User; @@ -66,6 +69,8 @@ export function MobileSidebar({ onWorkspaceSelect, onAddWorkspace, + isAtWorkspaceLimit = false, + maxWorkspacesPerUser, workspace, navItems, onNavItemClick, @@ -77,6 +82,7 @@ export function MobileSidebar({ onChatRename, onChatDelete, onChatArchive, + onChatsClick, onViewAllChats, isAllChatsActive = false, user, @@ -105,6 +111,10 @@ export function MobileSidebar({ onChatSelect(chat); onOpenChange(false); }; + const addWorkspaceLabel = + isAtWorkspaceLimit && maxWorkspacesPerUser !== undefined + ? `Workspace limit reached: ${maxWorkspacesPerUser}` + : "Add workspace"; return ( @@ -134,10 +144,12 @@ export function MobileSidebar({ variant="ghost" size="icon" onClick={onAddWorkspace} + disabled={isAtWorkspaceLimit} + title={addWorkspaceLabel} className="h-10 w-10 shrink-0 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50" > - Add workspace + {addWorkspaceLabel}
@@ -194,6 +206,14 @@ export function MobileSidebar({ onChatRename={onChatRename} onChatDelete={onChatDelete} onChatArchive={onChatArchive} + onChatsClick={ + onChatsClick + ? () => { + onOpenChange(false); + onChatsClick(); + } + : undefined + } onViewAllChats={ onViewAllChats ? () => { diff --git a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx index 7862ae90e..e24523448 100644 --- a/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx +++ b/surfsense_web/components/layout/ui/sidebar/Sidebar.tsx @@ -1,6 +1,6 @@ "use client"; -import { CreditCard, SquarePen, Zap } from "lucide-react"; +import { CreditCard, MessageCircleMore, SquarePen, Zap } from "lucide-react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -62,6 +62,7 @@ interface SidebarProps { onChatRename?: (chat: ChatItem) => void; onChatDelete?: (chat: ChatItem) => void; onChatArchive?: (chat: ChatItem) => void; + onChatsClick?: () => void; onViewAllChats?: () => void; isAllChatsActive?: boolean; user: User; @@ -101,6 +102,7 @@ export function Sidebar({ onChatRename, onChatDelete, onChatArchive, + onChatsClick, onViewAllChats, isAllChatsActive = false, user, @@ -222,6 +224,16 @@ export function Sidebar({ onScroll={(event) => setIsSidebarNavScrolled(event.currentTarget.scrollTop > 0)} >
+ {onChatsClick && ( + + )} {automationsItem && ( void; - onTabPrefetch?: (tab: Tab) => void; + onTabSwitch?: (tab: ResolvedTab) => void; + onTabPrefetch?: (tab: ResolvedTab) => void; onNewChat?: () => void; leftActions?: React.ReactNode; rightActions?: React.ReactNode; @@ -43,7 +38,7 @@ export function TabBar({ rightActions, className, }: TabBarProps) { - const tabs = useAtomValue(tabsAtom); + const tabs = useResolvedTabs(); const activeTabId = useAtomValue(activeTabIdAtom); const switchTab = useSetAtom(switchTabAtom); const closeTab = useSetAtom(closeTabAtom); @@ -65,7 +60,7 @@ export function TabBar({ ); const handleTabClick = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.id === activeTabId) return; switchTab(tab.id); onTabSwitch?.(tab); @@ -74,7 +69,7 @@ export function TabBar({ ); const handleTabPrefetch = useCallback( - (tab: Tab) => { + (tab: ResolvedTab) => { if (tab.type === "chat") { onTabPrefetch?.(tab); } @@ -87,10 +82,21 @@ export function TabBar({ e.stopPropagation(); const fallback = closeTab(tabId); if (fallback) { - onTabSwitch?.(fallback); + const resolvedFallback = + tabs.find((tab) => tab.id === fallback.id) ?? + (fallback.type === "chat" + ? { + ...fallback, + title: "New Chat", + chatUrl: getChatUrl(fallback.workspaceId, fallback.entityId), + } + : undefined); + if (resolvedFallback) { + onTabSwitch?.(resolvedFallback); + } } }, - [closeTab, onTabSwitch] + [closeTab, onTabSwitch, tabs] ); // React to tab list growth via a MutationObserver so the scroll catches the diff --git a/surfsense_web/components/mcp/connect-agent-dialog.tsx b/surfsense_web/components/mcp/connect-agent-dialog.tsx index 964605006..e07c80eb2 100644 --- a/surfsense_web/components/mcp/connect-agent-dialog.tsx +++ b/surfsense_web/components/mcp/connect-agent-dialog.tsx @@ -43,7 +43,7 @@ export function ConnectAgentDialog({ className }: { className?: string }) { - Connect to Claude Code, Codex, OpenCode… + Connect your coding agent to SurfSense Give your coding agent access to SurfSense scrapers and your knowledge base. Create an API key under API Keys, choose your agent, then paste the config. diff --git a/surfsense_web/components/new-chat/chat-header.tsx b/surfsense_web/components/new-chat/chat-header.tsx index e83a95d6c..f315f7645 100644 --- a/surfsense_web/components/new-chat/chat-header.tsx +++ b/surfsense_web/components/new-chat/chat-header.tsx @@ -1,5 +1,6 @@ "use client"; +import { cn } from "@/lib/utils"; import { ImageModelSelector } from "./image-model-selector"; import { ModelSelector } from "./model-selector"; @@ -10,14 +11,16 @@ interface ChatHeaderProps { } export function ChatHeader({ workspaceId, className, onChatModelSelected }: ChatHeaderProps) { + const selectorClassName = cn(className, "sm:max-w-[180px] sm:min-w-0"); + return ( -
+
- +
); } diff --git a/surfsense_web/components/new-chat/document-mention-picker.tsx b/surfsense_web/components/new-chat/document-mention-picker.tsx index dca3b5f7f..17a24f799 100644 --- a/surfsense_web/components/new-chat/document-mention-picker.tsx +++ b/surfsense_web/components/new-chat/document-mention-picker.tsx @@ -8,7 +8,7 @@ import { ChevronRight, Files, Folder as FolderIcon, - MessageSquare, + MessageCircleMore, Unplug, } from "lucide-react"; import { @@ -146,7 +146,7 @@ export function promoteRecentMention(workspaceId: number, mention: MentionedDocu function getMentionIcon(mention: MentionedDocumentInfo) { if (mention.kind === "folder") return ; - if (mention.kind === "thread") return ; + if (mention.kind === "thread") return ; if (mention.kind === "connector") { return getConnectorIcon(mention.connector_type, "size-4") ?? ; } @@ -517,7 +517,7 @@ export const DocumentMentionPicker = forwardRef< id: "chats", label: "Chats", subtitle: "Reference another conversation", - icon: , + icon: , type: "branch", value: { kind: "view", view: { kind: "chats" } }, }); @@ -565,7 +565,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: mention.title, subtitle: "Chat", - icon: , + icon: , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, @@ -625,7 +625,7 @@ export const DocumentMentionPicker = forwardRef< id: getMentionDocKey(mention), label: mention.title, subtitle: "Chat", - icon: , + icon: , type: "item" as const, disabled: selectedKeys.has(getMentionDocKey(mention)), value: { kind: "mention" as const, mention }, diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 1fce9848c..076d10255 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -18,6 +18,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; @@ -444,39 +445,45 @@ export function ReportPanelContent({ return ( <> {showDesktopHeader ? ( - <> +
{/* Header — matches the Documents panel header pattern */} -
-

{isResume ? "Resume" : "Report"}

- {onClose && ( - - )} -
- - {!isResume && ( -
-
-

- {reportContent?.title || title} -

-
-
- {versionSwitcher} - {exportButton} - {copyButton} - {editingActions} -
+
+
+

+ {isResume ? "Resume" : reportContent?.title || title} +

- )} - +
+ {!isResume && ( + <> + {versionSwitcher} + {exportButton} + {copyButton} + {editingActions} + + )} + {onClose && ( + <> + {!isResume && ( + + )} + + + )} +
+
+
) : ( !isResume && (
diff --git a/surfsense_web/contracts/types/workspace.types.ts b/surfsense_web/contracts/types/workspace.types.ts index a777f810a..8a9502131 100644 --- a/surfsense_web/contracts/types/workspace.types.ts +++ b/surfsense_web/contracts/types/workspace.types.ts @@ -29,6 +29,13 @@ export const getWorkspacesRequest = z.object({ export const getWorkspacesResponse = z.array(workspace); +/** + * Workspace limits + */ +export const workspaceLimits = z.object({ + max_workspaces_per_user: z.number(), +}); + /** * Create workspace */ @@ -94,6 +101,7 @@ export const leaveWorkspaceResponse = z.object({ // Inferred types export type Workspace = z.infer; +export type WorkspaceLimits = z.infer; export type GetWorkspacesRequest = z.infer; export type GetWorkspacesResponse = z.infer; export type CreateWorkspaceRequest = z.infer; diff --git a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx index 7b3567d73..8c5b575ad 100644 --- a/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx +++ b/surfsense_web/features/chat-artifacts/ui/artifacts-panel.tsx @@ -70,19 +70,25 @@ export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) { return ( <> -
-

Artifacts

- {onClose && ( - - )} +
+
+
+

Artifacts

+
+
+ {onClose && ( + + )} +
+
diff --git a/surfsense_web/hooks/use-activate-chat-thread.ts b/surfsense_web/hooks/use-activate-chat-thread.ts index 2b68ef76f..c0712908f 100644 --- a/surfsense_web/hooks/use-activate-chat-thread.ts +++ b/surfsense_web/hooks/use-activate-chat-thread.ts @@ -45,17 +45,13 @@ export function useActivateChatThread() { ); const activateChatThread = useCallback( - ({ id, title, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => { + ({ id, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => { const numericWorkspaceId = getWorkspaceId(workspaceId); const chatUrl = url ?? getChatUrl(workspaceId, id); syncChatTab({ chatId: id, - title: id ? title : (title ?? "New Chat"), - chatUrl, workspaceId: numericWorkspaceId, - ...(visibility !== undefined ? { visibility } : {}), - ...(hasComments !== undefined ? { hasComments } : {}), }); setCurrentThreadMetadata({ diff --git a/surfsense_web/hooks/use-resolved-tabs.ts b/surfsense_web/hooks/use-resolved-tabs.ts new file mode 100644 index 000000000..99bc90100 --- /dev/null +++ b/surfsense_web/hooks/use-resolved-tabs.ts @@ -0,0 +1,162 @@ +"use client"; + +import { useQueries } from "@tanstack/react-query"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useEffect, useMemo } from "react"; +import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom"; +import { documentsApiService } from "@/lib/apis/documents-api.service"; +import { type ChatVisibility, getThreadFull } from "@/lib/chat/thread-persistence"; +import { NotFoundError } from "@/lib/error"; +import { cacheKeys } from "@/lib/query-client/cache-keys"; + +// Thread/document metadata is read-only for tabs: the DB is the single source +// of truth and react-query is the cache (default staleTime from the shared +// QueryClient). Titles/visibility stay fresh because the rename/visibility/ +// delete mutations patch these same query caches (see lib/chat/thread-cache.ts), +// so a rename reflects in the tab bar immediately regardless of staleness. +interface ThreadRow { + id: number; + title: string; + visibility: string; +} + +interface DocumentRow { + id: number; + title: string; +} + +export interface ResolvedTab extends Tab { + title: string; + chatUrl?: string; + visibility?: ChatVisibility; +} + +function uniqueEntityIds(tabs: Tab[], type: Tab["type"]): number[] { + const ids = new Set(); + for (const tab of tabs) { + if (tab.type === type && tab.entityId !== null) { + ids.add(tab.entityId); + } + } + return [...ids]; +} + +function rowById(rows: readonly T[] | undefined): Map { + return new Map((rows ?? []).map((row) => [row.id, row])); +} + +// Retry transient failures (network, 5xx) but never a definitive 404 — a +// missing thread/document is authoritative and should settle immediately so +// the tab can be pruned rather than spun on. +function retryUnlessNotFound(failureCount: number, error: Error): boolean { + return !(error instanceof NotFoundError) && failureCount < 2; +} + +export function getChatUrl(workspaceId: number, threadId: number | null): string { + return threadId + ? `/dashboard/${workspaceId}/new-chat/${threadId}` + : `/dashboard/${workspaceId}/new-chat`; +} + +export function resolveTabPointers({ + tabs, + threadRows, + documentRows, +}: { + tabs: Tab[]; + threadRows?: readonly ThreadRow[]; + documentRows?: readonly DocumentRow[]; +}): ResolvedTab[] { + const threads = rowById(threadRows); + const documents = rowById(documentRows); + + return tabs.map((tab) => { + if (tab.type === "document") { + const title = + tab.entityId === null + ? "Document" + : (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`); + return { ...tab, title }; + } + + const row = tab.entityId === null ? undefined : threads.get(tab.entityId); + return { + ...tab, + title: row?.title || "New Chat", + chatUrl: getChatUrl(tab.workspaceId, tab.entityId), + ...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}), + }; + }); +} + +/** + * A chat tab is prunable only once its thread metadata fetch has settled as a + * definitive 404 (thread deleted). Transient network/5xx errors are excluded so + * an outage never wrongly closes open tabs. + */ +export function getMissingChatIds({ + tabs, + notFoundIds, +}: { + tabs: Tab[]; + notFoundIds: Set; +}): Set { + return new Set( + tabs + .filter( + (tab): tab is Tab & { type: "chat"; entityId: number } => + tab.type === "chat" && tab.entityId !== null && notFoundIds.has(tab.entityId) + ) + .map((tab) => tab.entityId) + ); +} + +export function useResolvedTabs(): ResolvedTab[] { + const tabs = useAtomValue(tabsAtom); + const pruneMissingChatTabs = useSetAtom(pruneMissingChatTabsAtom); + + const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]); + const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]); + + const threadResults = useQueries({ + queries: chatIds.map((id) => ({ + queryKey: cacheKeys.threads.detail(id), + queryFn: () => getThreadFull(id), + retry: retryUnlessNotFound, + })), + }); + + const documentResults = useQueries({ + queries: documentIds.map((id) => ({ + queryKey: cacheKeys.documents.document(String(id)), + queryFn: () => documentsApiService.getDocument({ id }), + retry: retryUnlessNotFound, + })), + }); + + const threadRows: ThreadRow[] = threadResults.flatMap((result, index) => + result.data + ? [{ id: chatIds[index], title: result.data.title, visibility: result.data.visibility }] + : [] + ); + const documentRows: DocumentRow[] = documentResults.flatMap((result, index) => + result.data ? [{ id: documentIds[index], title: result.data.title }] : [] + ); + + // Stable primitive key of the threads that settled as 404, so the prune + // effect fires only when that set changes — not on every react-query render. + const notFoundChatIdsKey = threadResults + .flatMap((result, index) => (result.error instanceof NotFoundError ? [chatIds[index]] : [])) + .sort((a, b) => a - b) + .join(","); + + useEffect(() => { + const notFoundIds = new Set( + notFoundChatIdsKey ? notFoundChatIdsKey.split(",").map(Number) : [] + ); + const missing = getMissingChatIds({ tabs, notFoundIds }); + if (missing.size > 0) pruneMissingChatTabs(missing); + }, [notFoundChatIdsKey, tabs, pruneMissingChatTabs]); + + return resolveTabPointers({ tabs, threadRows, documentRows }); +} diff --git a/surfsense_web/lib/apis/workspaces-api.service.ts b/surfsense_web/lib/apis/workspaces-api.service.ts index 8e1494e00..0bd0370a5 100644 --- a/surfsense_web/lib/apis/workspaces-api.service.ts +++ b/surfsense_web/lib/apis/workspaces-api.service.ts @@ -19,11 +19,16 @@ import { updateWorkspaceApiAccessResponse, updateWorkspaceRequest, updateWorkspaceResponse, + workspaceLimits, } from "@/contracts/types/workspace.types"; import { ValidationError } from "../error"; import { baseApiService } from "./base-api.service"; class WorkspacesApiService { + getWorkspaceLimits = async () => { + return baseApiService.get(`/api/v1/workspaces/limits`, workspaceLimits); + }; + /** * Get a list of workspaces with optional filtering and pagination */ diff --git a/surfsense_web/lib/chat/can-submit-chat.ts b/surfsense_web/lib/chat/can-submit-chat.ts new file mode 100644 index 000000000..106b298af --- /dev/null +++ b/surfsense_web/lib/chat/can-submit-chat.ts @@ -0,0 +1,23 @@ +export interface CanSubmitChatInput { + isLoadingMessages: boolean; + isThreadRunning: boolean; + isBlockedByOtherUser: boolean; + isComposerEmpty: boolean; + isWorkspaceChatReady: boolean; +} + +export function canSubmitChat({ + isLoadingMessages, + isThreadRunning, + isBlockedByOtherUser, + isComposerEmpty, + isWorkspaceChatReady, +}: CanSubmitChatInput): boolean { + return ( + !isLoadingMessages && + !isThreadRunning && + !isBlockedByOtherUser && + !isComposerEmpty && + isWorkspaceChatReady + ); +} diff --git a/surfsense_web/lib/format-date.ts b/surfsense_web/lib/format-date.ts index a062b08fa..06ae77c64 100644 --- a/surfsense_web/lib/format-date.ts +++ b/surfsense_web/lib/format-date.ts @@ -5,29 +5,29 @@ import { isThisYear, isToday, isTomorrow, - isYesterday, } from "date-fns"; /** * Format a date string as a human-readable relative time * - < 1 min: "Just now" - * - < 60 min: "15m ago" - * - Today: "Today, 2:30 PM" - * - Yesterday: "Yesterday, 2:30 PM" - * - < 7 days: "3d ago" + * - < 60 min: "15 minutes ago" + * - < 24 hours: "21 hours ago" + * - < 7 days: "2 days ago" + * - Older this year: "Jan 15" * - Older: "Jan 15, 2026" */ export function formatRelativeDate(dateString: string): string { const date = new Date(dateString); const now = new Date(); - const minutesAgo = differenceInMinutes(now, date); - const daysAgo = differenceInDays(now, date); + const minutesAgo = Math.max(0, differenceInMinutes(now, date)); + const hoursAgo = Math.floor(minutesAgo / 60); + const daysAgo = Math.floor(hoursAgo / 24); if (minutesAgo < 1) return "Just now"; - if (minutesAgo < 60) return `${minutesAgo}m ago`; - if (isToday(date)) return `Today, ${format(date, "h:mm a")}`; - if (isYesterday(date)) return `Yesterday, ${format(date, "h:mm a")}`; - if (daysAgo < 7) return `${daysAgo}d ago`; + if (minutesAgo < 60) return `${minutesAgo} minute${minutesAgo === 1 ? "" : "s"} ago`; + if (hoursAgo < 24) return `${hoursAgo} hour${hoursAgo === 1 ? "" : "s"} ago`; + if (daysAgo < 7) return `${daysAgo} day${daysAgo === 1 ? "" : "s"} ago`; + if (isThisYear(date)) return format(date, "MMM d"); return format(date, "MMM d, yyyy"); } diff --git a/surfsense_web/lib/query-client/cache-keys.ts b/surfsense_web/lib/query-client/cache-keys.ts index 13c141f69..9fc2ef2cb 100644 --- a/surfsense_web/lib/query-client/cache-keys.ts +++ b/surfsense_web/lib/query-client/cache-keys.ts @@ -49,6 +49,7 @@ export const cacheKeys = { }, workspaces: { all: ["workspaces"] as const, + limits: ["workspaces", "limits"] as const, withQueryParams: (queries: GetWorkspacesRequest["queryParams"]) => ["workspaces", ...stableEntries(queries)] as const, detail: (workspaceId: string) => ["workspaces", workspaceId] as const, diff --git a/surfsense_web/zero/schema/chat.ts b/surfsense_web/zero/schema/chat.ts index d42a11848..c2790d5bc 100644 --- a/surfsense_web/zero/schema/chat.ts +++ b/surfsense_web/zero/schema/chat.ts @@ -20,6 +20,9 @@ export const newChatMessageTable = table("new_chat_messages") }) .primaryKey("id"); +// Published only as the authz parent of chat messages/comments/session-state +// (whereExists("thread") + space constraint). Title/visibility are resolved +// over REST (react-query) for the tab bar, not synced through Zero. export const newChatThreadTable = table("new_chat_threads") .columns({ id: number(),