mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
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:
commit
110102c3c0
39 changed files with 893 additions and 603 deletions
|
|
@ -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))" }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ export function FreeLayoutDataProvider({ children }: FreeLayoutDataProviderProps
|
|||
onLogout={() => router.push("/register")}
|
||||
pageUsage={pageUsage}
|
||||
isChatPage
|
||||
showTabs={false}
|
||||
isLoadingChats={false}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -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 || "",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
? () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue