Merge pull request #1609 from AnishSarkar22/fix/tabs

refactor(tabs): pointer-based tabs with live react-query resolution, workspace limits, and UI polish
This commit is contained in:
Rohan Verma 2026-07-22 14:53:29 -07:00 committed by GitHub
commit 110102c3c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
39 changed files with 893 additions and 603 deletions

View file

@ -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")

View file

@ -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,

View file

@ -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",

View file

@ -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

View file

@ -13,21 +13,18 @@ export function AutomationsLoadingRows() {
return (
<>
{ROW_KEYS.map((key) => (
<TableRow key={key} className="border-b border-border/60 hover:bg-transparent">
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
<div className="flex flex-col gap-1.5">
<Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-56" />
</div>
<TableRow key={key} className="h-12 border-b border-border/60 hover:bg-transparent">
<TableCell className="px-4 md:px-6 py-2.5 border-r border-border/60 align-middle">
<Skeleton className="h-4 w-32 max-w-full md:w-40" />
</TableCell>
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
<TableCell className="px-4 py-2.5 border-r border-border/60 w-32 align-middle">
<Skeleton className="h-5 w-16 rounded-md" />
</TableCell>
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40">
<TableCell className="hidden md:table-cell px-4 py-2.5 border-r border-border/60 w-40 align-middle">
<Skeleton className="h-3 w-20" />
</TableCell>
<TableCell className="px-4 md:px-6 py-3 w-16">
<Skeleton className="h-8 w-8 rounded-md ml-auto" />
<TableCell className="px-4 md:px-6 py-2.5 w-16 align-middle">
<Skeleton className="h-7 w-7 rounded-md ml-auto" />
</TableCell>
</TableRow>
))}

View file

@ -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 (
<div
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-panel"
style={{
["--thread-max-width" as string]: "42rem",
}}
>
<div
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
style={{ scrollbarGutter: "stable" }}
>
<div
aria-hidden
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-panel from-20% to-transparent"
/>
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
<div className="flex justify-end">
<Skeleton className="h-12 w-[65%] max-w-56 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-[78%] max-w-72 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-[85%] max-w-96 rounded-2xl" />
</div>
</div>
<div
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-panel from-60% to-transparent px-4 pt-6"
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
>
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
<Skeleton className="h-28 w-full rounded-3xl" />
</div>
</div>
</div>
</div>
);
}
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<ThreadMessageLike[]>(displayMessages);
messagesRef.current = displayMessages;
const messagesRef = useRef<ThreadMessageLike[]>(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() {
>
<div key={workspaceId} className="flex h-full overflow-hidden">
<div className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
<Thread hasActiveThread={!!activeThreadId} />
{isThreadMessagesLoading ? (
<div className="absolute inset-0 z-10 bg-panel">
<ThreadMessagesSkeleton />
</div>
) : null}
<Thread
hasActiveThread={!!activeThreadId}
isLoadingMessages={isThreadMessagesLoading}
/>
</div>
<MobileReportPanel />
<MobileEditorPanel />

View file

@ -1,62 +0,0 @@
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
style={{
["--thread-max-width" as string]: "42rem",
}}
>
<div
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
style={{ scrollbarGutter: "stable" }}
>
<div
aria-hidden
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
/>
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
{/* User message */}
<div className="flex justify-end">
<Skeleton className="h-12 w-56 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-72 rounded-2xl" />
</div>
{/* Assistant message */}
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
{/* User message */}
<div className="flex gap-2 justify-end">
<Skeleton className="h-12 w-96 rounded-2xl" />
</div>
</div>
{/* Input bar */}
<div
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
>
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
<Skeleton className="h-28 w-full rounded-3xl" />
</div>
</div>
</div>
</div>
);
}

View file

@ -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);
});

View file

@ -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<T extends { tabs: Array<{ workspaceId?: number }> }>(
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;
}),
};
}

View file

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

View file

@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
export const activeWorkspaceIdAtom = atom<string | null>(null);
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
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),

View file

@ -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<ChatViewportProps> = ({ children, footer }) => (
export const ChatViewport: FC<ChatViewportProps> = ({
children,
footer,
footerAlwaysVisible = false,
}) => (
<ThreadPrimitive.Viewport
turnAnchor="top"
autoScroll
@ -40,7 +50,7 @@ export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
/>
{children}
{footer ? (
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<AuiIf condition={({ thread }) => footerAlwaysVisible || !thread.isEmpty}>
<ThreadPrimitive.ViewportFooter
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}

View file

@ -100,7 +100,7 @@ const NumericChunkCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
<DrawerTitle>Citation</DrawerTitle>
</DrawerHeader>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<CitationPanelContent chunkId={chunkId} showHeader={false} />
<CitationPanelContent chunkId={chunkId} />
</div>
</DrawerContent>
</Drawer>

View file

@ -733,15 +733,9 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const editableProps = useMemo(
() => ({
placeholder,
onPaste: (e: React.ClipboardEvent<HTMLDivElement>) => {
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<MentionEditorContextValue>(

View file

@ -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<ThreadProps> = ({ hasActiveThread = false }) => {
return <ThreadContent hasActiveThread={hasActiveThread} />;
export const Thread: FC<ThreadProps> = ({ hasActiveThread = false, isLoadingMessages = false }) => {
return (
<ThreadContent hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} />
);
};
const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
const ThreadContent: FC<ThreadProps> = ({
hasActiveThread = false,
isLoadingMessages = false,
}) => {
return (
<ThreadPrimitive.Root
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
@ -162,17 +169,20 @@ const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
}}
>
<ChatViewport
footerAlwaysVisible={hasActiveThread}
footer={
<AuiIf condition={({ thread }) => hasActiveThread || !thread.isEmpty}>
<>
<PremiumQuotaPinnedAlert />
<Composer />
</AuiIf>
<Composer hasActiveThread={hasActiveThread} isLoadingMessages={isLoadingMessages} />
</>
}
>
<AuiIf condition={({ thread }) => !hasActiveThread && thread.isEmpty}>
<ThreadWelcome />
</AuiIf>
{isLoadingMessages ? <ThreadMessagesSkeletonBody /> : null}
<ThreadPrimitive.Messages
components={{
UserMessage,
@ -185,6 +195,36 @@ const ThreadContent: FC<ThreadProps> = ({ hasActiveThread = false }) => {
);
};
const ThreadMessagesSkeletonBody: FC = () => {
return (
<div className="mx-auto flex w-full max-w-(--thread-max-width) flex-1 flex-col gap-6 py-8">
<div className="flex justify-end">
<Skeleton className="h-12 w-[65%] max-w-56 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-[85%]" />
<Skeleton className="h-18 w-[40%]" />
</div>
<div className="flex justify-end gap-2">
<Skeleton className="h-12 w-[78%] max-w-72 rounded-2xl" />
</div>
<div className="flex flex-col gap-2">
<Skeleton className="h-10 w-[30%]" />
<Skeleton className="h-4 w-[90%]" />
<Skeleton className="h-6 w-[60%]" />
</div>
<div className="flex justify-end gap-2">
<Skeleton className="h-12 w-[85%] max-w-96 rounded-2xl" />
</div>
</div>
);
};
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<ComposerProps> = ({
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 = () => {
</div>
<ComposerAction
isBlockedByOtherUser={isBlockedByOtherUser}
isLoadingMessages={isLoadingMessages}
isThreadRunning={isThreadRunning}
workspaceId={workspaceId ?? 0}
onChatModelSelected={handleChatModelSelected}
/>
</div>
<ConnectToolsBanner
isThreadEmpty={isThreadEmpty}
isThreadEmpty={!hasActiveThread && isThreadEmpty}
onVisibleChange={setConnectToolsTrayVisible}
/>
{isThreadEmpty && isComposerInputEmpty ? (
{!isLoadingMessages && isThreadEmpty && isComposerInputEmpty ? (
<div className="absolute top-full left-0 right-0 z-20">
<ChatExamplePrompts onSelect={handleExampleSelect} />
</div>
@ -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<ComposerActionProps> = ({
isBlockedByOtherUser = false,
isLoadingMessages = false,
isThreadRunning = false,
workspaceId,
onChatModelSelected,
}) => {
@ -1210,7 +1265,20 @@ const ComposerAction: FC<ComposerActionProps> = ({
// 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 (
<div className="aui-composer-action-wrapper relative mx-3 mb-3 flex items-center justify-between">
@ -1652,22 +1720,16 @@ const ComposerAction: FC<ComposerActionProps> = ({
)}
<ConnectedScraperIcons workspaceId={workspaceId} />
</div>
<div className="ml-auto flex min-w-0 shrink-0 items-center gap-2">
<div className="ml-auto flex min-w-0 shrink items-center gap-2">
<ChatHeader
workspaceId={workspaceId}
className="h-9 max-w-[44vw] px-2 sm:max-w-[220px] sm:px-3"
className="h-9 max-w-[44vw] px-2 sm:max-w-none sm:px-3"
onChatModelSelected={onChatModelSelected}
/>
<AuiIf condition={({ thread }) => !thread.isRunning}>
<ComposerPrimitive.Send asChild disabled={isSendDisabled}>
<TooltipIconButton
tooltip={
isBlockedByOtherUser
? "Wait for AI to finish responding"
: isComposerEmpty
? "Enter a message or add a screenshot to send"
: "Send message"
}
tooltip={sendTooltip}
side="bottom"
type="submit"
variant="default"

View file

@ -8,6 +8,7 @@ import { useEffect, useMemo, useRef } from "react";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { documentsApiService } from "@/lib/apis/documents-api.service";
@ -16,7 +17,6 @@ const DEFAULT_CHUNK_WINDOW = 5;
interface CitationPanelContentProps {
chunkId: number;
onClose?: () => 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<CitationPanelContentProps> = ({
chunkId,
onClose,
showHeader = true,
}) => {
export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, onClose }) => {
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const chunkWindow = DEFAULT_CHUNK_WINDOW;
@ -85,32 +81,13 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
return (
<>
<div className="shrink-0">
{showHeader && (
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
<h2 className="select-none text-lg font-semibold">Citation</h2>
<div className="flex items-center gap-1 shrink-0">
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close citation panel</span>
</Button>
)}
</div>
</div>
)}
<div className="grid h-10 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
<div className="grid h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
<div className="min-w-0 flex flex-1 items-center gap-2">
<p className="truncate text-sm text-muted-foreground">
{data?.title ?? (isLoading ? "Loading…" : `Chunk #${chunkId}`)}
</p>
</div>
<div className="flex items-center gap-3 shrink-0 text-[11px] text-muted-foreground">
{totalChunks > 0 && <span>{totalChunks} chunks</span>}
<div className="flex items-center gap-1 shrink-0">
{!isLoading && !error && data && (
<Button
variant="default"
@ -121,6 +98,23 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({
Open
</Button>
)}
{onClose && (
<>
<Separator
orientation="vertical"
className="mx-1.5 bg-muted-foreground/20 data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-muted-foreground/25"
/>
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="size-6 shrink-0 rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<XIcon className="size-4" />
<span className="sr-only">Close citation panel</span>
</Button>
</>
)}
</div>
</div>
</div>

View file

@ -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<string | null>(null);
const [saving, setSaving] = useState(false);
const [downloading, setDownloading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [memoryLimits, setMemoryLimits] = useState<MemoryLimits | null>(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 && (
<Alert className="m-4 shrink-0">
<FileText className="size-4" />
<AlertDescription className="flex items-center justify-between gap-4">
<span>
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.
</span>
<Button
variant="outline"
size="sm"
className="relative shrink-0"
disabled={downloading}
onClick={handleDownloadMarkdown}
>
<span className={`flex items-center gap-1.5 ${downloading ? "opacity-0" : ""}`}>
<Download className="size-3.5" />
Download .md
</span>
{downloading && <Spinner size="sm" className="absolute" />}
</Button>
</AlertDescription>
</Alert>
);
return (
<>
{showDesktopHeader ? (
@ -807,7 +749,6 @@ export function EditorPanelContent({
) : viewerMode === "monaco" && !isLocalFileMode ? (
// Large doc — raw markdown in Monaco. Rich renderers are intentionally skipped.
<div className="flex h-full min-h-0 flex-col">
{largeDocAlert}
<div className="min-h-0 flex-1 overflow-hidden">
<SourceCodeEditor
path={`${editorDoc.title || "document"}.md`}

View file

@ -85,6 +85,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
onLogout={() => router.push("/register")}
pageUsage={pageUsage}
isChatPage
showTabs={false}
isLoadingChats={false}
>
{children}

View file

@ -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<ChatItem>((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 || "",

View file

@ -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);
}
};

View file

@ -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 (
<div className={cn("flex h-full w-14 min-h-0 flex-col items-center", className)}>
@ -99,18 +107,21 @@ export function IconRail({
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
</Button>
<span className="inline-flex">
<Button
variant="ghost"
size="icon"
onClick={onAddWorkspace}
disabled={isAtWorkspaceLimit}
className="h-10 w-10 rounded-lg border-2 border-dashed border-muted-foreground/30 hover:border-muted-foreground/50 disabled:opacity-50"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">{addWorkspaceLabel}</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
Add workspace
{addWorkspaceLabel}
</TooltipContent>
</Tooltip>

View file

@ -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 (
<UntabbedMainContentPanel isChatPage={isChatPage} showTopBorder={showTopBorder}>
{children}
</UntabbedMainContentPanel>
);
}
return (
<TabbedMainContentPanel
isChatPage={isChatPage}
onTabSwitch={onTabSwitch}
onTabPrefetch={onTabPrefetch}
onNewChat={onNewChat}
showRightPanelExpandButton={showRightPanelExpandButton}
showTopBorder={showTopBorder}
>
{children}
</TabbedMainContentPanel>
);
}
function UntabbedMainContentPanel({
isChatPage,
showTopBorder,
children,
}: {
isChatPage: boolean;
showTopBorder: boolean;
children: React.ReactNode;
}) {
return (
<div
className={cn("relative isolate flex flex-1 flex-col min-w-0", showTopBorder && "border-t")}
>
<div className="relative flex flex-1 flex-col bg-panel overflow-hidden min-w-0">
<Header />
<div className={cn("flex-1", isChatPage ? "overflow-hidden" : "overflow-auto")}>
{children}
</div>
</div>
</div>
);
}
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({
<div className="relative flex flex-1 flex-col bg-panel overflow-hidden min-w-0">
<Header />
{isDocumentTab && activeTab.documentId && activeTab.workspaceId ? (
{isDocumentTab && activeTab.entityId && activeTab.workspaceId ? (
<div className="flex-1 overflow-hidden">
<DocumentTabContent
key={activeTab.documentId}
documentId={activeTab.documentId}
key={activeTab.entityId}
documentId={activeTab.entityId}
workspaceId={activeTab.workspaceId}
title={activeTab.title}
/>
@ -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}
>

View file

@ -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 && (
<Button
@ -307,10 +300,12 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
{[75, 90, 55, 80, 65, 85].map((titleWidth) => (
<div
key={`skeleton-${titleWidth}`}
className="flex items-center gap-2.5 rounded-md px-3 py-2.5"
className="rounded-md px-3 py-1.5 md:py-2"
>
<Skeleton className="h-4 w-4 shrink-0 rounded" />
<Skeleton className="h-5 rounded" style={{ width: `${titleWidth}%` }} />
<Skeleton
className="h-4 rounded md:h-4.5"
style={{ width: `${titleWidth}%` }}
/>
</div>
))}
</div>
@ -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) {
<span className="min-w-0 flex-1 truncate">{thread.title || "New Chat"}</span>
</Button>
) : (
<Tooltip delayDuration={600}>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread)}
onMouseEnter={() => prefetchChatThread(thread.id)}
onFocus={() => prefetchChatThread(thread.id)}
disabled={isBusy}
className={cn(
"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",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="min-w-0 flex-1 truncate">
{thread.title || "New Chat"}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" align="start">
<p>
{t("updated") || "Updated"}: {formatThreadTimestamp(thread.updatedAt)}
</p>
</TooltipContent>
</Tooltip>
<Button
type="button"
variant="ghost"
onClick={() => handleThreadClick(thread)}
onMouseEnter={() => prefetchChatThread(thread.id)}
onFocus={() => prefetchChatThread(thread.id)}
disabled={isBusy}
className={cn(
"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-44" : "pr-36",
isActive && "bg-accent text-accent-foreground",
isBusy && "opacity-50 pointer-events-none"
)}
>
<span className="min-w-0 flex-1 truncate">{thread.title || "New Chat"}</span>
</Button>
)}
<div
@ -400,28 +384,30 @@ function AllChatsContent({ workspaceId, className }: AllChatsContentProps) {
isActive
? "bg-gradient-to-l from-accent from-60% to-transparent"
: "bg-gradient-to-l from-sidebar from-60% to-transparent group-hover/item:from-accent",
isMobile
? "opacity-0"
: thread.visibility === "SEARCH_SPACE" || openDropdownId === thread.id
? "opacity-100"
: "opacity-0 group-hover/item:opacity-100"
"opacity-100"
)}
>
<div className="relative flex h-7 w-14 items-center justify-end">
{thread.visibility === "SEARCH_SPACE" ? (
<Badge
variant="secondary"
className={cn(
"absolute right-0 h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground transition-opacity hover:bg-popover-foreground/10",
!isMobile &&
(openDropdownId === thread.id
? "opacity-0"
: "opacity-100 group-hover/item:opacity-0")
)}
>
Shared
</Badge>
) : null}
<div className="relative flex h-7 w-40 items-center justify-end">
<div
className={cn(
"absolute right-1 flex items-center justify-end gap-2 transition-opacity",
openDropdownId === thread.id
? "opacity-0"
: "opacity-100 group-hover/item:opacity-0"
)}
>
{thread.visibility === "SEARCH_SPACE" ? (
<Badge
variant="secondary"
className="h-5 shrink-0 rounded-sm border-0 bg-popover-foreground/10 px-1.5 text-[11px] text-popover-foreground hover:bg-popover-foreground/10"
>
Shared
</Badge>
) : null}
<span className="whitespace-nowrap text-xs text-muted-foreground">
{formatRelativeDate(thread.updatedAt)}
</span>
</div>
<DropdownMenu
open={openDropdownId === thread.id}
onOpenChange={(isOpen) => 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) {
</div>
);
})}
{isLoadingMoreThreads ? (
<div className="mt-1 space-y-1">
{[62, 48, 56].map((titleWidth) => (
<div
key={`tail-skeleton-${titleWidth}`}
className="rounded-md px-3 py-1.5 md:py-2"
>
<Skeleton
className="h-4 rounded md:h-4.5"
style={{ width: `${titleWidth}%` }}
/>
</div>
))}
</div>
) : null}
</div>
) : isSearchMode ? (
<div className="text-center py-8">

View file

@ -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 (
<Sheet open={isOpen} onOpenChange={onOpenChange}>
@ -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"
>
<Plus className="h-5 w-5 text-muted-foreground" />
<span className="sr-only">Add workspace</span>
<span className="sr-only">{addWorkspaceLabel}</span>
</Button>
</div>
</ScrollArea>
@ -194,6 +206,14 @@ export function MobileSidebar({
onChatRename={onChatRename}
onChatDelete={onChatDelete}
onChatArchive={onChatArchive}
onChatsClick={
onChatsClick
? () => {
onOpenChange(false);
onChatsClick();
}
: undefined
}
onViewAllChats={
onViewAllChats
? () => {

View file

@ -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)}
>
<div className="flex flex-col gap-0.5 pt-0.5 pb-1.5">
{onChatsClick && (
<SidebarButton
icon={MessageCircleMore}
label={t("chats") || "Chats"}
onClick={onChatsClick}
isCollapsed={isCollapsed}
isActive={isAllChatsActive}
tooltipContent={isCollapsed ? t("chats") || "Chats" : undefined}
/>
)}
{automationsItem && (
<SidebarButton
icon={automationsItem.icon}

View file

@ -3,19 +3,14 @@
import { useAtomValue, useSetAtom } from "jotai";
import { Plus, X } from "lucide-react";
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import {
activeTabIdAtom,
closeTabAtom,
switchTabAtom,
type Tab,
tabsAtom,
} from "@/atoms/tabs/tabs.atom";
import { activeTabIdAtom, closeTabAtom, switchTabAtom } from "@/atoms/tabs/tabs.atom";
import { Button } from "@/components/ui/button";
import { getChatUrl, type ResolvedTab, useResolvedTabs } from "@/hooks/use-resolved-tabs";
import { cn } from "@/lib/utils";
interface TabBarProps {
onTabSwitch?: (tab: Tab) => 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

View file

@ -43,7 +43,7 @@ export function ConnectAgentDialog({ className }: { className?: string }) {
</DialogTrigger>
<DialogContent className="max-h-[85vh] min-w-0 overflow-x-hidden overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Connect to Claude Code, Codex, OpenCode</DialogTitle>
<DialogTitle>Connect your coding agent to SurfSense</DialogTitle>
<DialogDescription>
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.

View file

@ -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 (
<div className="flex min-w-0 shrink-0 items-center gap-2">
<div className="flex min-w-0 shrink items-center gap-2 sm:max-w-[360px]">
<ModelSelector
workspaceId={workspaceId}
className={className}
className={selectorClassName}
onChatModelSelected={onChatModelSelected}
/>
<ImageModelSelector workspaceId={workspaceId} className={className} mobileIconOnly />
<ImageModelSelector workspaceId={workspaceId} className={selectorClassName} mobileIconOnly />
</div>
);
}

View file

@ -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 <FolderIcon className="size-4" />;
if (mention.kind === "thread") return <MessageSquare className="size-4" />;
if (mention.kind === "thread") return <MessageCircleMore className="size-4" />;
if (mention.kind === "connector") {
return getConnectorIcon(mention.connector_type, "size-4") ?? <Unplug className="size-4" />;
}
@ -517,7 +517,7 @@ export const DocumentMentionPicker = forwardRef<
id: "chats",
label: "Chats",
subtitle: "Reference another conversation",
icon: <MessageSquare className="size-4" />,
icon: <MessageCircleMore className="size-4" />,
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: <MessageSquare className="size-4" />,
icon: <MessageCircleMore className="size-4" />,
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: <MessageSquare className="size-4" />,
icon: <MessageCircleMore className="size-4" />,
type: "item" as const,
disabled: selectedKeys.has(getMentionDocKey(mention)),
value: { kind: "mention" as const, mention },

View file

@ -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 ? (
<>
<div className="shrink-0">
{/* Header — matches the Documents panel header pattern */}
<div className="shrink-0 flex h-12 items-center justify-between px-3 border-b">
<h2 className="select-none text-lg font-semibold">{isResume ? "Resume" : "Report"}</h2>
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 rounded-full shrink-0 text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close report panel</span>
</Button>
)}
</div>
{!isResume && (
<div className="flex h-10 items-center justify-between gap-2 border-b px-4 shrink-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-muted-foreground">
{reportContent?.title || title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{versionSwitcher}
{exportButton}
{copyButton}
{editingActions}
</div>
<div className="grid h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
<div className="min-w-0 flex flex-1 items-center gap-2">
<p className="truncate text-sm text-muted-foreground">
{isResume ? "Resume" : reportContent?.title || title}
</p>
</div>
)}
</>
<div className="flex items-center gap-1 shrink-0">
{!isResume && (
<>
{versionSwitcher}
{exportButton}
{copyButton}
{editingActions}
</>
)}
{onClose && (
<>
{!isResume && (
<Separator
orientation="vertical"
className="mx-1.5 bg-muted-foreground/20 data-[orientation=vertical]:h-4 data-[orientation=vertical]:w-px dark:bg-muted-foreground/25"
/>
)}
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="size-6 shrink-0 rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<XIcon className="size-4" />
<span className="sr-only">Close report panel</span>
</Button>
</>
)}
</div>
</div>
</div>
) : (
!isResume && (
<div className="flex h-14 items-center justify-between border-b px-4 shrink-0">

View file

@ -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<typeof workspace>;
export type WorkspaceLimits = z.infer<typeof workspaceLimits>;
export type GetWorkspacesRequest = z.infer<typeof getWorkspacesRequest>;
export type GetWorkspacesResponse = z.infer<typeof getWorkspacesResponse>;
export type CreateWorkspaceRequest = z.infer<typeof createWorkspaceRequest>;

View file

@ -70,19 +70,25 @@ export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) {
return (
<>
<div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
<h2 className="select-none text-lg font-semibold">Artifacts</h2>
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-accent-foreground"
>
<XIcon className="h-4 w-4" />
<span className="sr-only">Close artifacts panel</span>
</Button>
)}
<div className="shrink-0">
<div className="grid h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
<div className="min-w-0 flex flex-1 items-center gap-2">
<p className="truncate text-sm text-muted-foreground">Artifacts</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{onClose && (
<Button
variant="ghost"
size="icon"
onClick={onClose}
className="size-6 shrink-0 rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
>
<XIcon className="size-4" />
<span className="sr-only">Close artifacts panel</span>
</Button>
)}
</div>
</div>
</div>
<ArtifactGroups artifacts={artifacts} />
</>

View file

@ -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({

View file

@ -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<number>();
for (const tab of tabs) {
if (tab.type === type && tab.entityId !== null) {
ids.add(tab.entityId);
}
}
return [...ids];
}
function rowById<T extends { id: number }>(rows: readonly T[] | undefined): Map<number, T> {
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<number>;
}): Set<number> {
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 });
}

View file

@ -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
*/

View file

@ -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
);
}

View file

@ -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");
}

View file

@ -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,

View file

@ -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(),