diff --git a/src/components/assistant-ui/assistant-loader.tsx b/src/components/assistant-ui/assistant-loader.tsx
index 0ae83746..48736fce 100644
--- a/src/components/assistant-ui/assistant-loader.tsx
+++ b/src/components/assistant-ui/assistant-loader.tsx
@@ -1,33 +1,27 @@
"use client";
-import { useAuiState } from "@assistant-ui/react";
-import { DotLottieReact } from "@lottiefiles/dotlottie-react";
+import { lazy, Suspense } from "react";
import { useTheme } from "next-themes";
-export const AssistantLoader = () => {
- const { resolvedTheme } = useTheme();
- const isRunning = useAuiState(
- ({ message }) => (message as { status?: { type: string } })?.status?.type === "running"
- );
-
- const isMessageEmpty = useAuiState(({ message }) => {
- const msg = message as any;
- return !msg?.content || (Array.isArray(msg.content) && msg.content.length === 0);
- });
-
- if (!isRunning || !isMessageEmpty) return null;
+const DotLottieReact = lazy(() =>
+ import("@lottiefiles/dotlottie-react").then((m) => ({ default: m.DotLottieReact }))
+);
+export const AssistantLoaderVisual = () => {
+ const { resolvedTheme } = useTheme();
const lottieSrc = resolvedTheme === 'light' ? '/thinkexlight.lottie' : '/logo.lottie';
return (
-
+ }>
+
+
Thinking...
);
diff --git a/src/components/assistant-ui/markdown-text.tsx b/src/components/assistant-ui/markdown-text.tsx
index ba0518b9..ca4fce8b 100644
--- a/src/components/assistant-ui/markdown-text.tsx
+++ b/src/components/assistant-ui/markdown-text.tsx
@@ -5,13 +5,16 @@ import "streamdown/styles.css";
import { createCodePlugin } from "@streamdown/code";
import { mermaid } from "@streamdown/mermaid";
import { createMathPlugin } from "@streamdown/math";
-import { useMessagePartText, useAuiState, type TextMessagePartProps } from "@assistant-ui/react";
+import { useMessagePartText, type TextMessagePartProps } from "@assistant-ui/react";
import {
Children,
+ createContext,
isValidElement,
memo,
+ useContext,
useRef,
useEffect,
+ useMemo,
type ReactNode,
} from "react";
import type {
@@ -30,13 +33,23 @@ import { getCitationUrl } from "@/lib/utils/preprocess-latex";
import { resolveItemByPath } from "@/lib/ai/tools/workspace-search-utils";
import { preprocessLatex } from "@/lib/utils/preprocess-latex";
import { cn } from "@/lib/utils";
+import type { Item } from "@/lib/workspace-state/types";
const math = createMathPlugin({ singleDollarTextMath: true });
const code = createCodePlugin({
themes: ["one-dark-pro", "one-dark-pro"],
}) as CodeHighlighterPlugin;
-/** Parse page number from citation ref (e.g. "Title | quote | p. 5" or "Title | p. 5"). */
+type CitationContextValue = {
+ workspaceState: Item[];
+ navigateToItem: ReturnType;
+ openWorkspaceItem: (id: string) => void;
+ setCitationHighlightQuery: (query: { itemId: string; query: string; pageNumber?: number }) => void;
+ citationUrls: Map;
+};
+
+const CitationContext = createContext(null);
+
function parseCitationPage(ref: string): { title: string; quote?: string; pageNumber?: number } {
const segments = ref.split(/\s*\|\s*/).map((s) => s.trim()).filter(Boolean);
if (segments.length === 0) return { title: "" };
@@ -56,7 +69,6 @@ function parseCitationPage(ref: string): { title: string; quote?: string; pageNu
return { title, quote: quote || undefined, pageNumber };
}
-/** Extract raw text from citation element children (handles nested elements). */
function extractCitationText(children: ReactNode): string {
if (typeof children === "string") return children.trim();
if (children == null) return "";
@@ -74,43 +86,34 @@ function extractCitationText(children: ReactNode): string {
.trim();
}
-/**
- * SurfSense-style citation renderer.
- * Content is the ref itself: urlciteN (URL), or Title, or Title|quote (workspace).
- */
const CitationRenderer = memo(
({ children }: { children?: ReactNode }) => {
+ const ctx = useContext(CitationContext);
const ref = extractCitationText(children);
if (!ref) return null;
- // URL placeholder (from preprocess): render as direct link
- const url = getCitationUrl(ref);
+ const url = ctx ? getCitationUrl(ref, ctx.citationUrls) : undefined;
if (url) {
return ;
}
- // Direct URL (in case preprocess didn't run, e.g. edge case)
if (ref.startsWith("http://") || ref.startsWith("https://")) {
return ;
}
- // Workspace citation: Title, Title|quote, or Title|quote|p. 5 (with optional page)
+ if (!ctx) return null;
+
+ const { workspaceState, navigateToItem, openWorkspaceItem, setCitationHighlightQuery } = ctx;
+
const parsed = parseCitationPage(ref);
const { title: parsedTitle, quote: parsedQuote, pageNumber } = parsed;
const title = parsedTitle;
const quote = parsedQuote;
- const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId);
- const { state: workspaceState } = useWorkspaceState(workspaceId);
- const navigateToItem = useNavigateToItem();
- const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem);
- // Normalize for matching: trim, lowercase, strip .pdf (model may include it; items often don't)
const titleNorm = (s: string) => s.trim().toLowerCase().replace(/\.pdf$/i, "");
- const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery);
const handleWorkspaceItemClick = () => {
if (!title) return;
- // Resolve by virtual path first (e.g. "pdfs/Syllabus.pdf") — AI may cite using paths from
const items = workspaceState;
const byPath = resolveItemByPath(items, title);
const item =
@@ -122,7 +125,6 @@ const CitationRenderer = memo(
titleNorm(i.name) === titleNorm(title)
);
if (!item) return;
- // Set citation highlight: for PDFs with page, or when we have a quote to search
if (quote?.trim() || (pageNumber != null && item.type === "pdf")) {
setCitationHighlightQuery({
itemId: item.id,
@@ -134,11 +136,10 @@ const CitationRenderer = memo(
openWorkspaceItem(item.id);
};
- // For path-like refs (e.g. pdfs/Syllabus.pdf), show basename in badge
const displayTitle = title.includes("/") ? title.split("/").pop() ?? title : title;
const badgeLabel =
- displayTitle.slice(0, 20) + (displayTitle.length > 20 ? "…" : "") +
- (pageNumber != null ? ` · p.${pageNumber}` : "");
+ displayTitle.slice(0, 20) + (displayTitle.length > 20 ? "\u2026" : "") +
+ (pageNumber != null ? ` \u00b7 p.${pageNumber}` : "");
return (
@@ -163,9 +164,34 @@ const CitationRenderer = memo(
);
CitationRenderer.displayName = "CitationRenderer";
-/** Props from assistant-ui when used as Text component, or optional when used directly (e.g. in Reasoning) */
+const MarkdownA = (props: AnchorHTMLAttributes & { node?: any }) => (
+
+);
+
+const MarkdownCitation = (props: any) => (
+ {props.children}
+);
+
+const MarkdownOl = ({ children, node, ...props }: HTMLAttributes & { node?: any }) => (
+
+ {children}
+
+);
+
+const MarkdownUl = ({ children, node, ...props }: HTMLAttributes & { node?: any }) => (
+
+);
+
+const STREAMDOWN_COMPONENTS = {
+ a: MarkdownA,
+ citation: MarkdownCitation,
+ ol: MarkdownOl,
+ ul: MarkdownUl,
+} as const;
+
type MarkdownTextProps = Partial & {
- /** Use "reasoning" for smoother streaming in reasoning blocks (blurIn, longer duration) */
streamingVariant?: "default" | "reasoning";
};
@@ -173,8 +199,39 @@ const MarkdownTextImpl = (props: MarkdownTextProps) => {
const streamingVariant = props.streamingVariant ?? "default";
const { text, status } = useMessagePartText();
- const threadId = useAuiState(({ threads }) => (threads as any)?.mainThreadId);
- const messageId = useAuiState(({ message }) => (message as any)?.id);
+ const workspaceId = useWorkspaceStore((s) => s.currentWorkspaceId);
+ const { state: workspaceState } = useWorkspaceState(workspaceId);
+ const navigateToItem = useNavigateToItem();
+ const openWorkspaceItem = useUIStore((s) => s.openWorkspaceItem);
+ const setCitationHighlightQuery = useUIStore((s) => s.setCitationHighlightQuery);
+
+ const citationUrlsRef = useRef