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
|
|
@ -13,21 +13,18 @@ export function AutomationsLoadingRows() {
|
|||
return (
|
||||
<>
|
||||
{ROW_KEYS.map((key) => (
|
||||
<TableRow key={key} className="border-b border-border/60 hover:bg-transparent">
|
||||
<TableCell className="px-4 md:px-6 py-3 border-r border-border/60">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
<Skeleton className="h-3 w-56" />
|
||||
</div>
|
||||
<TableRow key={key} className="h-12 border-b border-border/60 hover:bg-transparent">
|
||||
<TableCell className="px-4 md:px-6 py-2.5 border-r border-border/60 align-middle">
|
||||
<Skeleton className="h-4 w-32 max-w-full md:w-40" />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 border-r border-border/60 w-32">
|
||||
<TableCell className="px-4 py-2.5 border-r border-border/60 w-32 align-middle">
|
||||
<Skeleton className="h-5 w-16 rounded-md" />
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell px-4 py-3 border-r border-border/60 w-40">
|
||||
<TableCell className="hidden md:table-cell px-4 py-2.5 border-r border-border/60 w-40 align-middle">
|
||||
<Skeleton className="h-3 w-20" />
|
||||
</TableCell>
|
||||
<TableCell className="px-4 md:px-6 py-3 w-16">
|
||||
<Skeleton className="h-8 w-8 rounded-md ml-auto" />
|
||||
<TableCell className="px-4 md:px-6 py-2.5 w-16 align-middle">
|
||||
<Skeleton className="h-7 w-7 rounded-md ml-auto" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,6 @@ import {
|
|||
TokenUsageProvider,
|
||||
} from "@/components/assistant-ui/token-usage-context";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useSyncChatArtifacts } from "@/features/chat-artifacts";
|
||||
import {
|
||||
type HitlDecision,
|
||||
|
|
@ -104,6 +103,7 @@ const MobileArtifactsPanel = dynamic(
|
|||
|
||||
/** Stable empty reference so idle threads don't re-render the interrupt provider. */
|
||||
const EMPTY_PENDING_INTERRUPTS: PendingInterruptState[] = [];
|
||||
const EMPTY_MESSAGES: ThreadMessageLike[] = [];
|
||||
|
||||
function parseUrlChatId(id: string | string[] | undefined): number {
|
||||
let parsed = 0;
|
||||
|
|
@ -115,61 +115,6 @@ function parseUrlChatId(id: string | string[] | undefined): number {
|
|||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function ThreadMessagesSkeleton() {
|
||||
return (
|
||||
<div
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-panel"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "42rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-panel from-20% to-transparent"
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-12 w-[65%] max-w-56 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-18 w-[40%]" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-[78%] max-w-72 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-[30%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-6 w-[60%]" />
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-[85%] max-w-96 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<Skeleton className="h-28 w-full rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const params = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -226,6 +171,16 @@ export default function NewChatPage() {
|
|||
|
||||
const threadDetailQuery = useThreadDetail(activeThreadId);
|
||||
const threadMessagesQuery = useThreadMessages(activeThreadId);
|
||||
const hydratedMessagesRef = useRef<{
|
||||
threadId: number | null;
|
||||
data: typeof threadMessagesQuery.data;
|
||||
}>({ threadId: null, data: undefined });
|
||||
const hasLiveStream = !!streamState && streamState.messages.length > 0;
|
||||
const isActiveThreadHydrated = hydratedMessagesRef.current.threadId === activeThreadId;
|
||||
const shouldHideStaleMessages = !!activeThreadId && !hasLiveStream && !isActiveThreadHydrated;
|
||||
const isThreadMessagesLoading =
|
||||
shouldHideStaleMessages && !threadMessagesQuery.error;
|
||||
const runtimeMessages = shouldHideStaleMessages ? EMPTY_MESSAGES : displayMessages;
|
||||
|
||||
// Live collaboration: sync session state and messages via Zero. Kept on the
|
||||
// page because "AI responding" reflects the currently-viewed thread.
|
||||
|
|
@ -296,8 +251,8 @@ export default function NewChatPage() {
|
|||
|
||||
// Latest displayed messages, read by the engine wrappers at call time so
|
||||
// history/slice seeds stay fresh without re-creating the callbacks.
|
||||
const messagesRef = useRef<ThreadMessageLike[]>(displayMessages);
|
||||
messagesRef.current = displayMessages;
|
||||
const messagesRef = useRef<ThreadMessageLike[]>(runtimeMessages);
|
||||
messagesRef.current = runtimeMessages;
|
||||
|
||||
const buildCtx = useCallback(
|
||||
(): EngineContext => ({
|
||||
|
|
@ -309,11 +264,6 @@ export default function NewChatPage() {
|
|||
[workspaceId, activeThreadId]
|
||||
);
|
||||
|
||||
const hydratedMessagesRef = useRef<{
|
||||
threadId: number | null;
|
||||
data: typeof threadMessagesQuery.data;
|
||||
}>({ threadId: null, data: undefined });
|
||||
|
||||
// Reset thread-local runtime state on route/workspace changes. The durable
|
||||
// streaming overlay is preserved for any still-running thread (and the newly
|
||||
// viewed thread) via ``clearInactive`` so an in-flight turn survives nav.
|
||||
|
|
@ -349,11 +299,7 @@ export default function NewChatPage() {
|
|||
setCurrentThread(thread);
|
||||
syncChatTab({
|
||||
chatId: thread.id,
|
||||
title: thread.title,
|
||||
chatUrl: `/dashboard/${thread.workspace_id ?? workspaceId}/new-chat/${thread.id}`,
|
||||
workspaceId: thread.workspace_id ?? workspaceId,
|
||||
visibility: thread.visibility,
|
||||
hasComments: thread.has_comments ?? false,
|
||||
});
|
||||
}
|
||||
}, [activeThreadId, workspaceId, syncChatTab, threadDetailQuery.data]);
|
||||
|
|
@ -517,8 +463,11 @@ export default function NewChatPage() {
|
|||
|
||||
// Handle new message from user
|
||||
const onNew = useCallback(
|
||||
(message: AppendMessage) => startNewChat(buildCtx(), message),
|
||||
[buildCtx]
|
||||
(message: AppendMessage) => {
|
||||
if (isThreadMessagesLoading) return Promise.resolve();
|
||||
return startNewChat(buildCtx(), message);
|
||||
},
|
||||
[buildCtx, isThreadMessagesLoading]
|
||||
);
|
||||
|
||||
// Cancel the in-flight turn (targets the active stream's owner thread).
|
||||
|
|
@ -747,11 +696,11 @@ export default function NewChatPage() {
|
|||
}, [buildCtx, pendingInterrupts, activeThreadId]);
|
||||
|
||||
// Surface the thread's deliverables to the layout-level artifacts sidebar.
|
||||
useSyncChatArtifacts(displayMessages);
|
||||
useSyncChatArtifacts(runtimeMessages);
|
||||
|
||||
// Create external store runtime
|
||||
const runtime = useExternalStoreRuntime({
|
||||
messages: displayMessages,
|
||||
messages: runtimeMessages,
|
||||
isRunning,
|
||||
onNew,
|
||||
onEdit,
|
||||
|
|
@ -764,12 +713,7 @@ export default function NewChatPage() {
|
|||
? (threadDetailQuery.error ?? threadMessagesQuery.error)
|
||||
: null;
|
||||
const shouldShowThreadLoadError =
|
||||
!!threadLoadError && !!activeThreadId && !currentThread && displayMessages.length === 0;
|
||||
const isThreadMessagesLoading =
|
||||
!!activeThreadId &&
|
||||
threadMessagesQuery.isPending &&
|
||||
displayMessages.length === 0 &&
|
||||
!threadMessagesQuery.error;
|
||||
!!threadLoadError && !!activeThreadId && !currentThread && runtimeMessages.length === 0;
|
||||
|
||||
if (shouldShowThreadLoadError) {
|
||||
return (
|
||||
|
|
@ -798,12 +742,10 @@ export default function NewChatPage() {
|
|||
>
|
||||
<div key={workspaceId} className="flex h-full overflow-hidden">
|
||||
<div className="relative flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<Thread hasActiveThread={!!activeThreadId} />
|
||||
{isThreadMessagesLoading ? (
|
||||
<div className="absolute inset-0 z-10 bg-panel">
|
||||
<ThreadMessagesSkeleton />
|
||||
</div>
|
||||
) : null}
|
||||
<Thread
|
||||
hasActiveThread={!!activeThreadId}
|
||||
isLoadingMessages={isThreadMessagesLoading}
|
||||
/>
|
||||
</div>
|
||||
<MobileReportPanel />
|
||||
<MobileEditorPanel />
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div
|
||||
className="aui-root aui-thread-root @container flex h-full min-h-0 flex-col bg-main-panel"
|
||||
style={{
|
||||
["--thread-max-width" as string]: "42rem",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 scroll-smooth"
|
||||
style={{ scrollbarGutter: "stable" }}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className="aui-chat-viewport-top-fade pointer-events-none sticky top-0 z-10 -mx-4 h-2 shrink-0 bg-gradient-to-b from-main-panel from-20% to-transparent"
|
||||
/>
|
||||
<div className="mx-auto w-full max-w-(--thread-max-width) flex flex-1 flex-col gap-6 py-8">
|
||||
{/* User message */}
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-12 w-56 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-[85%]" />
|
||||
<Skeleton className="h-18 w-[40%]" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-72 rounded-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Assistant message */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Skeleton className="h-10 w-[30%]" />
|
||||
<Skeleton className="h-4 w-[90%]" />
|
||||
<Skeleton className="h-6 w-[60%]" />
|
||||
</div>
|
||||
|
||||
{/* User message */}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Skeleton className="h-12 w-96 rounded-2xl" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<div
|
||||
className="aui-chat-composer-footer sticky bottom-0 z-20 -mx-4 mt-auto flex flex-col items-stretch bg-gradient-to-t from-main-panel from-60% to-transparent px-4 pt-6"
|
||||
style={{ paddingBottom: "max(0.5rem, env(safe-area-inset-bottom))" }}
|
||||
>
|
||||
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
|
||||
<Skeleton className="h-28 w-full rounded-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { migrateLegacyTabs } from "./migrate-tabs";
|
||||
|
||||
// Run with: pnpm exec tsx --test atoms/tabs/migrate-tabs.test.ts
|
||||
test("maps legacy searchSpaceId to workspaceId on read", () => {
|
||||
const migrated = migrateLegacyTabs({
|
||||
tabs: [{ id: "chat-new", type: "chat", searchSpaceId: 7 } as never],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
const tab = migrated.tabs[0] as { workspaceId?: number };
|
||||
assert.equal(tab.workspaceId, 7);
|
||||
});
|
||||
|
||||
test("leaves an already-migrated workspaceId untouched", () => {
|
||||
const migrated = migrateLegacyTabs({
|
||||
tabs: [{ id: "d1", type: "document", workspaceId: 3, searchSpaceId: 9 } as never],
|
||||
});
|
||||
const tab = migrated.tabs[0] as { workspaceId?: number };
|
||||
assert.equal(tab.workspaceId, 3);
|
||||
});
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
/**
|
||||
* One-time read-migration for persisted tabs: legacy state stored the workspace
|
||||
* as `searchSpaceId`. Map it to `workspaceId` on read so already-open tabs keep
|
||||
* their workspace association after the rename. Pure + dependency-free so it can
|
||||
* be unit-checked without loading the atom module.
|
||||
*/
|
||||
export function migrateLegacyTabs<T extends { tabs: Array<{ workspaceId?: number }> }>(
|
||||
state: T
|
||||
): T {
|
||||
return {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) => {
|
||||
const legacy = t as { workspaceId?: number; searchSpaceId?: number };
|
||||
return legacy.workspaceId === undefined && legacy.searchSpaceId !== undefined
|
||||
? { ...t, workspaceId: legacy.searchSpaceId }
|
||||
: t;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,22 +1,13 @@
|
|||
import { atom } from "jotai";
|
||||
import { atomWithStorage, createJSONStorage } from "jotai/utils";
|
||||
import type { ChatVisibility } from "@/lib/chat/thread-persistence";
|
||||
import { migrateLegacyTabs } from "./migrate-tabs";
|
||||
|
||||
export type TabType = "chat" | "document";
|
||||
|
||||
export interface Tab {
|
||||
id: string;
|
||||
type: TabType;
|
||||
title: string;
|
||||
/** For chat tabs */
|
||||
chatId?: number | null;
|
||||
chatUrl?: string;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
/** For document tabs */
|
||||
documentId?: number;
|
||||
workspaceId?: number;
|
||||
entityId: number | null;
|
||||
workspaceId: number;
|
||||
}
|
||||
|
||||
interface TabsState {
|
||||
|
|
@ -27,9 +18,8 @@ interface TabsState {
|
|||
const INITIAL_CHAT_TAB: Tab = {
|
||||
id: "chat-new",
|
||||
type: "chat",
|
||||
title: "New Chat",
|
||||
chatId: null,
|
||||
chatUrl: undefined,
|
||||
entityId: null,
|
||||
workspaceId: 0,
|
||||
};
|
||||
|
||||
const initialState: TabsState = {
|
||||
|
|
@ -37,23 +27,14 @@ const initialState: TabsState = {
|
|||
activeTabId: "chat-new",
|
||||
};
|
||||
|
||||
// Prevent race conditions where route-sync recreates a just-deleted chat tab.
|
||||
const deletedChatIdsAtom = atom<Set<number>>(new Set<number>());
|
||||
|
||||
// Persist tabs in localStorage so they survive a hard refresh and let the user
|
||||
// keep tabs open across multiple workspaces (browser-like behavior).
|
||||
const localStorageAdapter = createJSONStorage<TabsState>(
|
||||
() => (typeof window !== "undefined" ? localStorage : undefined) as Storage
|
||||
);
|
||||
|
||||
// Wrap getItem in place so the adapter keeps its original (sync) type while
|
||||
// migrating legacy persisted state on read.
|
||||
const baseGetItem = localStorageAdapter.getItem.bind(localStorageAdapter);
|
||||
localStorageAdapter.getItem = (key, initialValue) =>
|
||||
migrateLegacyTabs(baseGetItem(key, initialValue));
|
||||
|
||||
export const tabsStateAtom = atomWithStorage<TabsState>(
|
||||
"surfsense:tabs",
|
||||
"surfsense:tabs:v2",
|
||||
initialState,
|
||||
localStorageAdapter,
|
||||
{ getOnInit: true }
|
||||
|
|
@ -66,11 +47,11 @@ export const activeTabAtom = atom((get) => {
|
|||
return state.tabs.find((t) => t.id === state.activeTabId) ?? null;
|
||||
});
|
||||
|
||||
function makeChatTabId(chatId: number | null): string {
|
||||
export function makeChatTabId(chatId: number | null): string {
|
||||
return chatId ? `chat-${chatId}` : "chat-new";
|
||||
}
|
||||
|
||||
function makeDocumentTabId(documentId: number): string {
|
||||
export function makeDocumentTabId(documentId: number): string {
|
||||
return `doc-${documentId}`;
|
||||
}
|
||||
|
||||
|
|
@ -86,24 +67,12 @@ export const syncChatTabAtom = atom(
|
|||
set,
|
||||
{
|
||||
chatId,
|
||||
title,
|
||||
chatUrl,
|
||||
workspaceId,
|
||||
visibility,
|
||||
hasComments,
|
||||
}: {
|
||||
chatId: number | null;
|
||||
title?: string;
|
||||
chatUrl?: string;
|
||||
workspaceId: number;
|
||||
visibility?: ChatVisibility;
|
||||
hasComments?: boolean;
|
||||
}
|
||||
) => {
|
||||
if (chatId && get(deletedChatIdsAtom).has(chatId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeChatTabId(chatId);
|
||||
const existing = state.tabs.find((t) => t.id === tabId);
|
||||
|
|
@ -116,11 +85,8 @@ export const syncChatTabAtom = atom(
|
|||
t.id === tabId
|
||||
? {
|
||||
...t,
|
||||
title: title || t.title,
|
||||
chatUrl: chatUrl || t.chatUrl,
|
||||
workspaceId: workspaceId ?? t.workspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
entityId: chatId,
|
||||
workspaceId,
|
||||
}
|
||||
: t
|
||||
),
|
||||
|
|
@ -136,11 +102,13 @@ export const syncChatTabAtom = atom(
|
|||
set(tabsStateAtom, {
|
||||
...state,
|
||||
activeTabId: "chat-new",
|
||||
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, workspaceId, chatUrl } : t)),
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === "chat-new" ? { ...t, entityId: null, workspaceId } : t
|
||||
),
|
||||
});
|
||||
} else {
|
||||
set(tabsStateAtom, {
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId, chatUrl }],
|
||||
tabs: [...state.tabs, { ...INITIAL_CHAT_TAB, workspaceId }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
}
|
||||
|
|
@ -152,12 +120,8 @@ export const syncChatTabAtom = atom(
|
|||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: "chat",
|
||||
title: title || "New Chat",
|
||||
chatId,
|
||||
chatUrl,
|
||||
entityId: chatId,
|
||||
workspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
};
|
||||
|
||||
let updatedTabs: Tab[];
|
||||
|
|
@ -172,29 +136,26 @@ export const syncChatTabAtom = atom(
|
|||
}
|
||||
);
|
||||
|
||||
/** Update the title of the current chat tab (e.g., when a chat gets its first response). */
|
||||
/** Promote the lazy "new chat" tab once the server creates its thread. */
|
||||
export const updateChatTabTitleAtom = atom(
|
||||
null,
|
||||
(get, set, { chatId, title }: { chatId: number; title: string }) => {
|
||||
(get, set, { chatId }: { chatId: number; title?: string }) => {
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeChatTabId(chatId);
|
||||
const hasExactTab = state.tabs.some((t) => t.id === tabId);
|
||||
|
||||
// During lazy thread creation, title updates can arrive before "chat-new"
|
||||
// is swapped to chat-{id}. In that case, promote the active "chat-new" tab.
|
||||
// During lazy thread creation, title updates can arrive before "chat-new" is
|
||||
// swapped to chat-{id}. In that case, promote the pointer only; title comes
|
||||
// from Zero.
|
||||
if (!hasExactTab && state.activeTabId === "chat-new") {
|
||||
set(tabsStateAtom, {
|
||||
...state,
|
||||
activeTabId: tabId,
|
||||
tabs: state.tabs.map((t) => (t.id === "chat-new" ? { ...t, id: tabId, chatId, title } : t)),
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.id === "chat-new" ? { ...t, id: tabId, entityId: chatId } : t
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
set(tabsStateAtom, {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title } : t)),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
|
|
@ -204,7 +165,7 @@ export const openDocumentTabAtom = atom(
|
|||
(
|
||||
get,
|
||||
set,
|
||||
{ documentId, workspaceId, title }: { documentId: number; workspaceId: number; title?: string }
|
||||
{ documentId, workspaceId }: { documentId: number; workspaceId: number; title?: string }
|
||||
) => {
|
||||
const state = get(tabsStateAtom);
|
||||
const tabId = makeDocumentTabId(documentId);
|
||||
|
|
@ -214,7 +175,7 @@ export const openDocumentTabAtom = atom(
|
|||
set(tabsStateAtom, {
|
||||
...state,
|
||||
activeTabId: tabId,
|
||||
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, title: title || t.title } : t)),
|
||||
tabs: state.tabs.map((t) => (t.id === tabId ? { ...t, workspaceId } : t)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -222,8 +183,7 @@ export const openDocumentTabAtom = atom(
|
|||
const newTab: Tab = {
|
||||
id: tabId,
|
||||
type: "document",
|
||||
title: title || `Document ${documentId}`,
|
||||
documentId,
|
||||
entityId: documentId,
|
||||
workspaceId,
|
||||
};
|
||||
|
||||
|
|
@ -254,11 +214,12 @@ export const closeTabAtom = atom(null, (get, set, tabId: string) => {
|
|||
|
||||
// Don't close the last tab — always keep at least one
|
||||
if (remaining.length === 0) {
|
||||
const closedTab = state.tabs[idx];
|
||||
set(tabsStateAtom, {
|
||||
tabs: [INITIAL_CHAT_TAB],
|
||||
tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
return INITIAL_CHAT_TAB;
|
||||
return { ...INITIAL_CHAT_TAB, workspaceId: closedTab.workspaceId };
|
||||
}
|
||||
|
||||
let newActiveId = state.activeTabId;
|
||||
|
|
@ -279,18 +240,16 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
|
|||
const idx = state.tabs.findIndex((t) => t.id === tabId);
|
||||
if (idx === -1) return null;
|
||||
|
||||
const deletedChatIds = get(deletedChatIdsAtom);
|
||||
set(deletedChatIdsAtom, new Set([...deletedChatIds, chatId]));
|
||||
|
||||
const remaining = state.tabs.filter((t) => t.id !== tabId);
|
||||
|
||||
// Always keep at least one tab available.
|
||||
if (remaining.length === 0) {
|
||||
const removedTab = state.tabs[idx];
|
||||
set(tabsStateAtom, {
|
||||
tabs: [INITIAL_CHAT_TAB],
|
||||
tabs: [{ ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId }],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
return INITIAL_CHAT_TAB;
|
||||
return { ...INITIAL_CHAT_TAB, workspaceId: removedTab.workspaceId };
|
||||
}
|
||||
|
||||
let newActiveId = state.activeTabId;
|
||||
|
|
@ -303,8 +262,34 @@ export const removeChatTabAtom = atom(null, (get, set, chatId: number) => {
|
|||
return remaining.find((t) => t.id === newActiveId) ?? null;
|
||||
});
|
||||
|
||||
/** Reset tabs when switching workspaces. */
|
||||
export const resetTabsAtom = atom(null, (_get, set) => {
|
||||
set(tabsStateAtom, { ...initialState });
|
||||
set(deletedChatIdsAtom, new Set<number>());
|
||||
/** Remove unresolved chat pointers after Zero confirms the queried rows are complete. */
|
||||
export const pruneMissingChatTabsAtom = atom(null, (get, set, missingChatIds: Set<number>) => {
|
||||
if (missingChatIds.size === 0) return;
|
||||
|
||||
const state = get(tabsStateAtom);
|
||||
const firstMissingIdx = state.tabs.findIndex(
|
||||
(t) => t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)
|
||||
);
|
||||
if (firstMissingIdx === -1) return;
|
||||
|
||||
const remaining = state.tabs.filter(
|
||||
(t) => !(t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId))
|
||||
);
|
||||
|
||||
if (remaining.length === 0) {
|
||||
set(tabsStateAtom, {
|
||||
tabs: [INITIAL_CHAT_TAB],
|
||||
activeTabId: "chat-new",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const activeWasPruned = state.tabs.some(
|
||||
(t) => t.id === state.activeTabId && t.type === "chat" && t.entityId !== null && missingChatIds.has(t.entityId)
|
||||
);
|
||||
const newActiveId = activeWasPruned
|
||||
? remaining[Math.min(firstMissingIdx, remaining.length - 1)].id
|
||||
: state.activeTabId;
|
||||
|
||||
set(tabsStateAtom, { tabs: remaining, activeTabId: newActiveId });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,14 +6,23 @@ import { cacheKeys } from "@/lib/query-client/cache-keys";
|
|||
|
||||
export const activeWorkspaceIdAtom = atom<string | null>(null);
|
||||
|
||||
export const workspacesQueryParamsAtom = atom<GetWorkspacesRequest["queryParams"]>({
|
||||
skip: 0,
|
||||
limit: 10,
|
||||
owned_only: false,
|
||||
export const workspaceLimitsAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.workspaces.limits,
|
||||
staleTime: Infinity,
|
||||
queryFn: async () => {
|
||||
return workspacesApiService.getWorkspaceLimits();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const workspacesAtom = atomWithQuery((get) => {
|
||||
const queryParams = get(workspacesQueryParamsAtom);
|
||||
const workspaceLimits = get(workspaceLimitsAtom).data;
|
||||
const queryParams: GetWorkspacesRequest["queryParams"] = {
|
||||
skip: 0,
|
||||
...(workspaceLimits ? { limit: workspaceLimits.max_workspaces_per_user } : {}),
|
||||
owned_only: false,
|
||||
};
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.workspaces.withQueryParams(queryParams),
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@ export const getWorkspacesRequest = z.object({
|
|||
|
||||
export const getWorkspacesResponse = z.array(workspace);
|
||||
|
||||
/**
|
||||
* Workspace limits
|
||||
*/
|
||||
export const workspaceLimits = z.object({
|
||||
max_workspaces_per_user: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create workspace
|
||||
*/
|
||||
|
|
@ -94,6 +101,7 @@ export const leaveWorkspaceResponse = z.object({
|
|||
|
||||
// Inferred types
|
||||
export type Workspace = z.infer<typeof workspace>;
|
||||
export type WorkspaceLimits = z.infer<typeof workspaceLimits>;
|
||||
export type GetWorkspacesRequest = z.infer<typeof getWorkspacesRequest>;
|
||||
export type GetWorkspacesResponse = z.infer<typeof getWorkspacesResponse>;
|
||||
export type CreateWorkspaceRequest = z.infer<typeof createWorkspaceRequest>;
|
||||
|
|
|
|||
|
|
@ -70,19 +70,25 @@ export function ArtifactsPanelContent({ onClose }: { onClose?: () => void }) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
|
||||
<h2 className="select-none text-lg font-semibold">Artifacts</h2>
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="h-8 w-8 shrink-0 rounded-full text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
<span className="sr-only">Close artifacts panel</span>
|
||||
</Button>
|
||||
)}
|
||||
<div className="shrink-0">
|
||||
<div className="grid h-12 grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border-b px-4">
|
||||
<div className="min-w-0 flex flex-1 items-center gap-2">
|
||||
<p className="truncate text-sm text-muted-foreground">Artifacts</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onClose && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="size-6 shrink-0 rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close artifacts panel</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ArtifactGroups artifacts={artifacts} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -45,17 +45,13 @@ export function useActivateChatThread() {
|
|||
);
|
||||
|
||||
const activateChatThread = useCallback(
|
||||
({ id, title, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => {
|
||||
({ id, url, workspaceId, visibility, hasComments }: ActivateChatThreadInput) => {
|
||||
const numericWorkspaceId = getWorkspaceId(workspaceId);
|
||||
const chatUrl = url ?? getChatUrl(workspaceId, id);
|
||||
|
||||
syncChatTab({
|
||||
chatId: id,
|
||||
title: id ? title : (title ?? "New Chat"),
|
||||
chatUrl,
|
||||
workspaceId: numericWorkspaceId,
|
||||
...(visibility !== undefined ? { visibility } : {}),
|
||||
...(hasComments !== undefined ? { hasComments } : {}),
|
||||
});
|
||||
|
||||
setCurrentThreadMetadata({
|
||||
|
|
|
|||
162
surfsense_web/hooks/use-resolved-tabs.ts
Normal file
162
surfsense_web/hooks/use-resolved-tabs.ts
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
"use client";
|
||||
|
||||
import { useQueries } from "@tanstack/react-query";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { pruneMissingChatTabsAtom, type Tab, tabsAtom } from "@/atoms/tabs/tabs.atom";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { type ChatVisibility, getThreadFull } from "@/lib/chat/thread-persistence";
|
||||
import { NotFoundError } from "@/lib/error";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
|
||||
// Thread/document metadata is read-only for tabs: the DB is the single source
|
||||
// of truth and react-query is the cache (default staleTime from the shared
|
||||
// QueryClient). Titles/visibility stay fresh because the rename/visibility/
|
||||
// delete mutations patch these same query caches (see lib/chat/thread-cache.ts),
|
||||
// so a rename reflects in the tab bar immediately regardless of staleness.
|
||||
interface ThreadRow {
|
||||
id: number;
|
||||
title: string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
interface DocumentRow {
|
||||
id: number;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ResolvedTab extends Tab {
|
||||
title: string;
|
||||
chatUrl?: string;
|
||||
visibility?: ChatVisibility;
|
||||
}
|
||||
|
||||
function uniqueEntityIds(tabs: Tab[], type: Tab["type"]): number[] {
|
||||
const ids = new Set<number>();
|
||||
for (const tab of tabs) {
|
||||
if (tab.type === type && tab.entityId !== null) {
|
||||
ids.add(tab.entityId);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function rowById<T extends { id: number }>(rows: readonly T[] | undefined): Map<number, T> {
|
||||
return new Map((rows ?? []).map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
// Retry transient failures (network, 5xx) but never a definitive 404 — a
|
||||
// missing thread/document is authoritative and should settle immediately so
|
||||
// the tab can be pruned rather than spun on.
|
||||
function retryUnlessNotFound(failureCount: number, error: Error): boolean {
|
||||
return !(error instanceof NotFoundError) && failureCount < 2;
|
||||
}
|
||||
|
||||
export function getChatUrl(workspaceId: number, threadId: number | null): string {
|
||||
return threadId
|
||||
? `/dashboard/${workspaceId}/new-chat/${threadId}`
|
||||
: `/dashboard/${workspaceId}/new-chat`;
|
||||
}
|
||||
|
||||
export function resolveTabPointers({
|
||||
tabs,
|
||||
threadRows,
|
||||
documentRows,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
threadRows?: readonly ThreadRow[];
|
||||
documentRows?: readonly DocumentRow[];
|
||||
}): ResolvedTab[] {
|
||||
const threads = rowById(threadRows);
|
||||
const documents = rowById(documentRows);
|
||||
|
||||
return tabs.map((tab) => {
|
||||
if (tab.type === "document") {
|
||||
const title =
|
||||
tab.entityId === null
|
||||
? "Document"
|
||||
: (documents.get(tab.entityId)?.title ?? `Document ${tab.entityId}`);
|
||||
return { ...tab, title };
|
||||
}
|
||||
|
||||
const row = tab.entityId === null ? undefined : threads.get(tab.entityId);
|
||||
return {
|
||||
...tab,
|
||||
title: row?.title || "New Chat",
|
||||
chatUrl: getChatUrl(tab.workspaceId, tab.entityId),
|
||||
...(row?.visibility !== undefined ? { visibility: row.visibility as ChatVisibility } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A chat tab is prunable only once its thread metadata fetch has settled as a
|
||||
* definitive 404 (thread deleted). Transient network/5xx errors are excluded so
|
||||
* an outage never wrongly closes open tabs.
|
||||
*/
|
||||
export function getMissingChatIds({
|
||||
tabs,
|
||||
notFoundIds,
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
notFoundIds: Set<number>;
|
||||
}): Set<number> {
|
||||
return new Set(
|
||||
tabs
|
||||
.filter(
|
||||
(tab): tab is Tab & { type: "chat"; entityId: number } =>
|
||||
tab.type === "chat" && tab.entityId !== null && notFoundIds.has(tab.entityId)
|
||||
)
|
||||
.map((tab) => tab.entityId)
|
||||
);
|
||||
}
|
||||
|
||||
export function useResolvedTabs(): ResolvedTab[] {
|
||||
const tabs = useAtomValue(tabsAtom);
|
||||
const pruneMissingChatTabs = useSetAtom(pruneMissingChatTabsAtom);
|
||||
|
||||
const chatIds = useMemo(() => uniqueEntityIds(tabs, "chat"), [tabs]);
|
||||
const documentIds = useMemo(() => uniqueEntityIds(tabs, "document"), [tabs]);
|
||||
|
||||
const threadResults = useQueries({
|
||||
queries: chatIds.map((id) => ({
|
||||
queryKey: cacheKeys.threads.detail(id),
|
||||
queryFn: () => getThreadFull(id),
|
||||
retry: retryUnlessNotFound,
|
||||
})),
|
||||
});
|
||||
|
||||
const documentResults = useQueries({
|
||||
queries: documentIds.map((id) => ({
|
||||
queryKey: cacheKeys.documents.document(String(id)),
|
||||
queryFn: () => documentsApiService.getDocument({ id }),
|
||||
retry: retryUnlessNotFound,
|
||||
})),
|
||||
});
|
||||
|
||||
const threadRows: ThreadRow[] = threadResults.flatMap((result, index) =>
|
||||
result.data
|
||||
? [{ id: chatIds[index], title: result.data.title, visibility: result.data.visibility }]
|
||||
: []
|
||||
);
|
||||
const documentRows: DocumentRow[] = documentResults.flatMap((result, index) =>
|
||||
result.data ? [{ id: documentIds[index], title: result.data.title }] : []
|
||||
);
|
||||
|
||||
// Stable primitive key of the threads that settled as 404, so the prune
|
||||
// effect fires only when that set changes — not on every react-query render.
|
||||
const notFoundChatIdsKey = threadResults
|
||||
.flatMap((result, index) => (result.error instanceof NotFoundError ? [chatIds[index]] : []))
|
||||
.sort((a, b) => a - b)
|
||||
.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
const notFoundIds = new Set(
|
||||
notFoundChatIdsKey ? notFoundChatIdsKey.split(",").map(Number) : []
|
||||
);
|
||||
const missing = getMissingChatIds({ tabs, notFoundIds });
|
||||
if (missing.size > 0) pruneMissingChatTabs(missing);
|
||||
}, [notFoundChatIdsKey, tabs, pruneMissingChatTabs]);
|
||||
|
||||
return resolveTabPointers({ tabs, threadRows, documentRows });
|
||||
}
|
||||
|
|
@ -19,11 +19,16 @@ import {
|
|||
updateWorkspaceApiAccessResponse,
|
||||
updateWorkspaceRequest,
|
||||
updateWorkspaceResponse,
|
||||
workspaceLimits,
|
||||
} from "@/contracts/types/workspace.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class WorkspacesApiService {
|
||||
getWorkspaceLimits = async () => {
|
||||
return baseApiService.get(`/api/v1/workspaces/limits`, workspaceLimits);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of workspaces with optional filtering and pagination
|
||||
*/
|
||||
|
|
|
|||
23
surfsense_web/lib/chat/can-submit-chat.ts
Normal file
23
surfsense_web/lib/chat/can-submit-chat.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
export interface CanSubmitChatInput {
|
||||
isLoadingMessages: boolean;
|
||||
isThreadRunning: boolean;
|
||||
isBlockedByOtherUser: boolean;
|
||||
isComposerEmpty: boolean;
|
||||
isWorkspaceChatReady: boolean;
|
||||
}
|
||||
|
||||
export function canSubmitChat({
|
||||
isLoadingMessages,
|
||||
isThreadRunning,
|
||||
isBlockedByOtherUser,
|
||||
isComposerEmpty,
|
||||
isWorkspaceChatReady,
|
||||
}: CanSubmitChatInput): boolean {
|
||||
return (
|
||||
!isLoadingMessages &&
|
||||
!isThreadRunning &&
|
||||
!isBlockedByOtherUser &&
|
||||
!isComposerEmpty &&
|
||||
isWorkspaceChatReady
|
||||
);
|
||||
}
|
||||
|
|
@ -5,29 +5,29 @@ import {
|
|||
isThisYear,
|
||||
isToday,
|
||||
isTomorrow,
|
||||
isYesterday,
|
||||
} from "date-fns";
|
||||
|
||||
/**
|
||||
* Format a date string as a human-readable relative time
|
||||
* - < 1 min: "Just now"
|
||||
* - < 60 min: "15m ago"
|
||||
* - Today: "Today, 2:30 PM"
|
||||
* - Yesterday: "Yesterday, 2:30 PM"
|
||||
* - < 7 days: "3d ago"
|
||||
* - < 60 min: "15 minutes ago"
|
||||
* - < 24 hours: "21 hours ago"
|
||||
* - < 7 days: "2 days ago"
|
||||
* - Older this year: "Jan 15"
|
||||
* - Older: "Jan 15, 2026"
|
||||
*/
|
||||
export function formatRelativeDate(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const minutesAgo = differenceInMinutes(now, date);
|
||||
const daysAgo = differenceInDays(now, date);
|
||||
const minutesAgo = Math.max(0, differenceInMinutes(now, date));
|
||||
const hoursAgo = Math.floor(minutesAgo / 60);
|
||||
const daysAgo = Math.floor(hoursAgo / 24);
|
||||
|
||||
if (minutesAgo < 1) return "Just now";
|
||||
if (minutesAgo < 60) return `${minutesAgo}m ago`;
|
||||
if (isToday(date)) return `Today, ${format(date, "h:mm a")}`;
|
||||
if (isYesterday(date)) return `Yesterday, ${format(date, "h:mm a")}`;
|
||||
if (daysAgo < 7) return `${daysAgo}d ago`;
|
||||
if (minutesAgo < 60) return `${minutesAgo} minute${minutesAgo === 1 ? "" : "s"} ago`;
|
||||
if (hoursAgo < 24) return `${hoursAgo} hour${hoursAgo === 1 ? "" : "s"} ago`;
|
||||
if (daysAgo < 7) return `${daysAgo} day${daysAgo === 1 ? "" : "s"} ago`;
|
||||
if (isThisYear(date)) return format(date, "MMM d");
|
||||
return format(date, "MMM d, yyyy");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const cacheKeys = {
|
|||
},
|
||||
workspaces: {
|
||||
all: ["workspaces"] as const,
|
||||
limits: ["workspaces", "limits"] as const,
|
||||
withQueryParams: (queries: GetWorkspacesRequest["queryParams"]) =>
|
||||
["workspaces", ...stableEntries(queries)] as const,
|
||||
detail: (workspaceId: string) => ["workspaces", workspaceId] as const,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,9 @@ export const newChatMessageTable = table("new_chat_messages")
|
|||
})
|
||||
.primaryKey("id");
|
||||
|
||||
// Published only as the authz parent of chat messages/comments/session-state
|
||||
// (whereExists("thread") + space constraint). Title/visibility are resolved
|
||||
// over REST (react-query) for the tab bar, not synced through Zero.
|
||||
export const newChatThreadTable = table("new_chat_threads")
|
||||
.columns({
|
||||
id: number(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue