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

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