Merge remote-tracking branch 'upstream/dev' into feat/ui-revamp

This commit is contained in:
Anish Sarkar 2026-05-02 12:54:49 +05:30
commit 9b1b5a504e
148 changed files with 19460 additions and 2708 deletions

View file

@ -17,16 +17,12 @@ import {
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import { getToolDisplayName, getToolIcon } from "@/contracts/enums/toolIcons";
import { type AgentAction, agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
interface ActionLogItemProps {
action: AgentAction;
threadId: number;
@ -43,7 +39,7 @@ export function ActionLogItem({ action, threadId, onRevertSuccess }: ActionLogIt
const hasError = action.error !== null && action.error !== undefined;
const Icon = getToolIcon(action.tool_name);
const displayName = formatToolName(action.tool_name);
const displayName = getToolDisplayName(action.tool_name);
const argsPreview = action.args ? JSON.stringify(action.args, null, 2) : null;
const truncatedArgs =

View file

@ -1,9 +1,9 @@
"use client";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useQueryClient } from "@tanstack/react-query";
import { useAtom, useAtomValue } from "jotai";
import { Activity, RefreshCcw } from "lucide-react";
import { useCallback, useMemo } from "react";
import { useCallback } from "react";
import { actionLogSheetAtom } from "@/atoms/agent/action-log-sheet.atom";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { Badge } from "@/components/ui/badge";
@ -17,15 +17,9 @@ import {
SheetTitle,
} from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { agentActionsQueryKey, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { ActionLogItem } from "./action-log-item";
const ACTION_LOG_PAGE_SIZE = 50;
function actionLogQueryKey(threadId: number) {
return ["agent-actions", threadId] as const;
}
function EmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
@ -85,25 +79,17 @@ export function ActionLogSheet() {
const threadId = state.threadId;
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: threadId !== null ? actionLogQueryKey(threadId) : ["agent-actions", "none"],
queryFn: () =>
agentActionsApiService.listForThread(threadId as number, {
page: 0,
pageSize: ACTION_LOG_PAGE_SIZE,
}),
enabled: state.open && threadId !== null && actionLogEnabled,
staleTime: 15 * 1000,
});
const { data, items, isLoading, isFetching, isError, error, refetch } = useAgentActionsQuery(
threadId,
{ enabled: state.open && actionLogEnabled }
);
const handleRevertSuccess = useCallback(() => {
if (threadId !== null) {
queryClient.invalidateQueries({ queryKey: actionLogQueryKey(threadId) });
queryClient.invalidateQueries({ queryKey: agentActionsQueryKey(threadId) });
}
}, [queryClient, threadId]);
const items = useMemo(() => data?.items ?? [], [data]);
return (
<Sheet open={state.open} onOpenChange={(open) => setState((s) => ({ ...s, open }))}>
<SheetContent

View file

@ -33,6 +33,8 @@ import {
useAllCitationMetadata,
} from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { RevertTurnButton } from "@/components/assistant-ui/revert-turn-button";
import { useTokenUsage } from "@/components/assistant-ui/token-usage-context";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
@ -491,6 +493,7 @@ const AssistantMessageInner: FC = () => {
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: {
generate_report: GenerateReportToolUI,
@ -545,8 +548,10 @@ const AssistantMessageInner: FC = () => {
</div>
)}
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 flex items-center gap-2">
<AssistantActionBar />
<div className="aui-assistant-message-footer mt-3 mb-5 ml-2 h-6">
<div className="h-full opacity-100 transition-opacity">
<AssistantActionBar />
</div>
</div>
</CitationMetadataProvider>
);
@ -639,35 +644,41 @@ export const AssistantMessage: FC = () => {
className="aui-assistant-message-root group fade-in slide-in-from-bottom-1 relative mx-auto w-full max-w-(--thread-max-width) animate-in py-3 duration-150"
data-role="assistant"
>
{/* Comment trigger — right-aligned, just below user query on all screen sizes */}
{showCommentTrigger && (
<div className="mr-2 mb-1 flex justify-end">
<button
ref={isDesktop ? commentTriggerRef : undefined}
type="button"
onClick={
isDesktop ? () => setIsInlineOpen((prev) => !prev) : () => setIsSheetOpen(true)
}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
isDesktop && isInlineOpen
? "bg-primary/10 text-primary"
: hasComments
? "text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
{hasComments ? (
<span>
{commentCount} {commentCount === 1 ? "comment" : "comments"}
</span>
) : (
<span>Add comment</span>
)}
</button>
</div>
)}
{/* Fixed trigger slot prevents any vertical reflow when visibility changes */}
<div className="mr-2 mb-1 flex h-7 justify-end">
<button
ref={isDesktop ? commentTriggerRef : undefined}
type="button"
onClick={
showCommentTrigger
? isDesktop
? () => setIsInlineOpen((prev) => !prev)
: () => setIsSheetOpen(true)
: undefined
}
aria-hidden={!showCommentTrigger}
tabIndex={showCommentTrigger ? 0 : -1}
className={cn(
"flex items-center gap-1.5 rounded-full px-3 py-1 text-sm transition-colors",
"opacity-0 pointer-events-none",
showCommentTrigger && "opacity-100 pointer-events-auto",
isDesktop && isInlineOpen
? "bg-primary/10 text-primary"
: hasComments
? "text-primary hover:bg-primary/10"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
<MessageCircleReply className={cn("size-3.5", hasComments && "fill-current")} />
{hasComments ? (
<span>
{commentCount} {commentCount === 1 ? "comment" : "comments"}
</span>
) : (
<span>Add comment</span>
)}
</button>
</div>
{/* Desktop floating comment panel — overlays on top of chat content */}
{showCommentTrigger && isDesktop && isInlineOpen && dbMessageId && (
@ -699,6 +710,13 @@ const AssistantActionBar: FC = () => {
const isLast = useAuiState((s) => s.message.isLast);
const aui = useAui();
const api = useElectronAPI();
// Surface the persisted ``chat_turn_id`` so the per-turn revert
// affordance can scope to just this message's actions. Streamed
// turns get their id once the assistant message is hydrated/finalised.
const chatTurnId = useAuiState(({ message }) => {
const meta = message?.metadata as { custom?: { chatTurnId?: string | null } } | undefined;
return meta?.custom?.chatTurnId ?? null;
});
const isQuickAssist = !!api?.replaceText && IS_QUICK_ASSIST_WINDOW;
@ -743,6 +761,9 @@ const AssistantActionBar: FC = () => {
</TooltipIconButton>
)}
<MessageInfoDropdown />
<div className="ml-auto">
<RevertTurnButton chatTurnId={chatTurnId} />
</div>
</ActionBarPrimitive.Root>
);
};

View file

@ -0,0 +1,52 @@
"use client";
import { ThreadPrimitive } from "@assistant-ui/react";
import { ArrowDownIcon } from "lucide-react";
import type { FC, ReactNode } from "react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
const ChatScrollToBottom: FC = () => (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
export interface ChatViewportProps {
children: ReactNode;
footer?: ReactNode;
}
export const ChatViewport: FC<ChatViewportProps> = ({ children, footer }) => (
<ThreadPrimitive.Viewport
turnAnchor="top"
autoScroll
scrollToBottomOnRunStart
scrollToBottomOnInitialize
scrollToBottomOnThreadSwitch
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"
/>
{children}
{footer ? (
<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))" }}
>
<div className="aui-chat-composer-area relative mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-3 overflow-visible">
<ChatScrollToBottom />
{footer}
</div>
</ThreadPrimitive.ViewportFooter>
) : null}
</ThreadPrimitive.Viewport>
);

View file

@ -0,0 +1,106 @@
"use client";
/**
* Confirmation dialog shown when the user edits a message that has
* reversible downstream actions. Three buttons:
*
* "Revert all & resubmit" POST regenerate with revert_actions=true
* "Continue without revert" POST regenerate with revert_actions=false
* "Cancel" abort the edit entirely
*
* The dialog is auto-skipped when zero reversible downstream actions
* exist (the caller checks first via ``downstreamReversibleCount``).
*/
import { useEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
export type EditMessageDialogChoice = "revert" | "continue" | "cancel";
export interface EditMessageDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
downstreamReversibleCount: number;
downstreamTotalCount: number;
onChoose: (choice: EditMessageDialogChoice) => void | Promise<void>;
}
export function EditMessageDialog({
open,
onOpenChange,
downstreamReversibleCount,
downstreamTotalCount,
onChoose,
}: EditMessageDialogProps) {
const [busy, setBusy] = useState<EditMessageDialogChoice | null>(null);
// The parent's ``handleEditDialogChoice`` calls
// ``setEditDialogState(null)`` BEFORE awaiting ``handleRegenerate``.
// That collapses the dialog (Radix unmounts it) while ``onChoose``
// is still awaiting the long-running stream. Without this guard,
// the ``finally { setBusy(null) }`` below ran after unmount and
// produced a "state update on unmounted component" dev warning.
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
const handle = async (choice: EditMessageDialogChoice) => {
setBusy(choice);
try {
await onChoose(choice);
} finally {
if (mountedRef.current) {
setBusy(null);
}
}
};
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Edit this message?</AlertDialogTitle>
<AlertDialogDescription>
This edit drops {downstreamTotalCount} downstream message
{downstreamTotalCount === 1 ? "" : "s"} from the thread. {downstreamReversibleCount}{" "}
action
{downstreamReversibleCount === 1 ? "" : "s"} (e.g. file writes, connector changes) can
be rolled back. Pick how to handle them before regenerating.
</AlertDialogDescription>
</AlertDialogHeader>
<div className="grid gap-2">
<Button variant="default" disabled={busy !== null} onClick={() => handle("revert")}>
{busy === "revert"
? "Reverting & resubmitting…"
: `Revert ${downstreamReversibleCount} action${
downstreamReversibleCount === 1 ? "" : "s"
} & resubmit`}
</Button>
<Button variant="outline" disabled={busy !== null} onClick={() => handle("continue")}>
{busy === "continue" ? "Resubmitting…" : "Continue without reverting"}
</Button>
</div>
<AlertDialogFooter className="sm:justify-start">
<AlertDialogCancel disabled={busy !== null} onClick={() => handle("cancel")}>
Cancel
</AlertDialogCancel>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View file

@ -3,11 +3,11 @@
import { useQuery } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import { ExternalLink, FileText } from "lucide-react";
import dynamic from "next/dynamic";
import type { FC } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { openCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { useCitationMetadata } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownViewer } from "@/components/markdown-viewer";
import { Citation } from "@/components/tool-ui/citation";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Spinner } from "@/components/ui/spinner";
@ -15,6 +15,16 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { cacheKeys } from "@/lib/query-client/cache-keys";
// Lazily load MarkdownViewer here to break the static import cycle:
// `markdown-viewer.tsx` → `citation-renderer.tsx` → `inline-citation.tsx`
// would otherwise pull `markdown-viewer.tsx` back in at module-init time.
// Only `SurfsenseDocCitation` (popover body) ever renders this viewer, so
// the lazy boundary is invisible to most call paths.
const MarkdownViewer = dynamic(
() => import("@/components/markdown-viewer").then((m) => m.MarkdownViewer),
{ ssr: false, loading: () => <Spinner size="xs" /> }
);
interface InlineCitationProps {
chunkId: number;
isDocsChunk?: boolean;
@ -172,7 +182,7 @@ const SurfsenseDocCitation: FC<{ chunkId: number }> = ({ chunkId }) => {
</p>
)}
{!isLoading && !error && citedChunk?.content && (
<MarkdownViewer content={citedChunk.content} maxLength={1500} />
<MarkdownViewer content={citedChunk.content} maxLength={1500} enableCitations />
)}
{!isLoading && !error && !citedChunk?.content && (
<p className="py-4 text-xs text-muted-foreground">No content available.</p>

File diff suppressed because it is too large Load diff

View file

@ -12,14 +12,15 @@ import { ExternalLinkIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
import { memo, type ReactNode } from "react";
import { createContext, memo, type ReactNode, useCallback, useContext, useRef } from "react";
import rehypeKatex from "rehype-katex";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { ImagePreview, ImageRoot, ImageZoom } from "@/components/assistant-ui/image";
import "katex/dist/katex.min.css";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import { toast } from "sonner";
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
@ -30,6 +31,8 @@ import {
TableRow,
} from "@/components/ui/table";
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
import { cn } from "@/lib/utils";
function MarkdownCodeBlockSkeleton() {
@ -59,31 +62,30 @@ const LazyMarkdownCodeBlock = dynamic(
}
);
// Storage for URL citations replaced during preprocess to avoid GFM autolink interference.
// Populated in preprocessMarkdown, consumed in parseTextWithCitations.
let _pendingUrlCitations = new Map<string, string>();
let _urlCiteIdx = 0;
// Per-render URL placeholder map propagated to component overrides via
// React Context. Replaces the previous module-level `_pendingUrlCitations`
// state, which was unsafe under concurrent renders / SSR.
type CitationUrlMapRef = { current: CitationUrlMap };
const EMPTY_URL_MAP: CitationUrlMap = new Map();
const CitationUrlMapContext = createContext<CitationUrlMapRef>({ current: EMPTY_URL_MAP });
function useCitationUrlMap(): CitationUrlMap {
return useContext(CitationUrlMapContext).current;
}
/**
* Preprocess raw markdown before it reaches the remark/rehype pipeline.
* - Replaces URL-based citations with safe placeholders (prevents GFM autolinks)
* - Normalises LaTeX delimiters to dollar-sign syntax for remark-math
*/
function preprocessMarkdown(content: string): string {
function preprocessMarkdown(content: string, urlMapRef: CitationUrlMapRef): string {
// Replace URL-based citations with safe placeholders BEFORE markdown parsing.
// GFM autolinks would otherwise convert the https://... inside [citation:URL]
// into an <a> element, splitting the text and preventing our citation regex
// from matching the full pattern.
_pendingUrlCitations = new Map();
_urlCiteIdx = 0;
content = content.replace(
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+)\s*\u200B?[\]】]/g,
(_, url) => {
const key = `urlcite${_urlCiteIdx++}`;
_pendingUrlCitations.set(key, url.trim());
return `[citation:${key}]`;
}
);
const { content: rewritten, urlMap } = preprocessCitationMarkdown(content);
urlMapRef.current = urlMap;
content = rewritten;
// All math forms are normalised to $$...$$ so we can disable single-dollar
// inline math in remark-math (otherwise currency like "$3,120.00 and $0.00"
@ -116,113 +118,25 @@ function preprocessMarkdown(content: string): string {
return content;
}
// Matches [citation:...] with numeric IDs (incl. negative, doc- prefix, comma-separated),
// URL-based IDs from live web search, or urlciteN placeholders from preprocess.
// Also matches Chinese brackets 【】 and handles zero-width spaces that LLM sometimes inserts.
const CITATION_REGEX =
/[[【]\u200B?citation:\s*(https?:\/\/[^\]】\u200B]+|urlcite\d+|(?:doc-)?-?\d+(?:\s*,\s*(?:doc-)?-?\d+)*)\s*\u200B?[\]】]/g;
/**
* Parses text and replaces [citation:XXX] patterns with citation components.
* Supports:
* - Numeric chunk IDs: [citation:123]
* - Doc-prefixed IDs: [citation:doc-123]
* - Comma-separated IDs: [citation:4149, 4150, 4151]
* - URL-based citations from live search: [citation:https://example.com/page]
*/
function parseTextWithCitations(text: string): ReactNode[] {
const parts: ReactNode[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let instanceIndex = 0;
CITATION_REGEX.lastIndex = 0;
match = CITATION_REGEX.exec(text);
while (match !== null) {
if (match.index > lastIndex) {
parts.push(text.substring(lastIndex, match.index));
}
const captured = match[1];
if (captured.startsWith("http://") || captured.startsWith("https://")) {
parts.push(<UrlCitation key={`citation-url-${instanceIndex}`} url={captured.trim()} />);
instanceIndex++;
} else if (captured.startsWith("urlcite")) {
const url = _pendingUrlCitations.get(captured);
if (url) {
parts.push(<UrlCitation key={`citation-url-${instanceIndex}`} url={url} />);
}
instanceIndex++;
} else {
const rawIds = captured.split(",").map((s) => s.trim());
for (const rawId of rawIds) {
const isDocsChunk = rawId.startsWith("doc-");
const chunkId = Number.parseInt(isDocsChunk ? rawId.slice(4) : rawId, 10);
parts.push(
<InlineCitation
key={`citation-${isDocsChunk ? "doc-" : ""}${chunkId}-${instanceIndex}`}
chunkId={chunkId}
isDocsChunk={isDocsChunk}
/>
);
instanceIndex++;
}
}
lastIndex = match.index + match[0].length;
match = CITATION_REGEX.exec(text);
}
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
return parts.length > 0 ? parts : [text];
}
const MarkdownTextImpl = () => {
const urlMapRef = useRef<CitationUrlMap>(EMPTY_URL_MAP);
const preprocess = useCallback((content: string) => preprocessMarkdown(content, urlMapRef), []);
return (
<MarkdownTextPrimitive
smooth={false}
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[rehypeKatex]}
className="aui-md"
components={defaultComponents}
preprocess={preprocessMarkdown}
/>
<CitationUrlMapContext.Provider value={urlMapRef}>
<MarkdownTextPrimitive
smooth={false}
remarkPlugins={[remarkGfm, [remarkMath, { singleDollarTextMath: false }]]}
rehypePlugins={[rehypeKatex]}
className="aui-md"
components={defaultComponents}
preprocess={preprocess}
/>
</CitationUrlMapContext.Provider>
);
};
export const MarkdownText = memo(MarkdownTextImpl);
/**
* Helper to process children and replace citation patterns with components
*/
function processChildrenWithCitations(children: ReactNode): ReactNode {
if (typeof children === "string") {
const parsed = parseTextWithCitations(children);
return parsed.length === 1 && typeof parsed[0] === "string" ? children : parsed;
}
if (Array.isArray(children)) {
return children.map((child) => {
if (typeof child === "string") {
const parsed = parseTextWithCitations(child);
return parsed.length === 1 && typeof parsed[0] === "string" ? (
child
) : (
<span key={child}>{parsed}</span>
);
}
return child;
});
}
return children;
}
function extractDomain(url: string): string {
try {
const parsed = new URL(url);
@ -282,6 +196,85 @@ function isVirtualFilePathToken(value: string): boolean {
return segments.length >= 2;
}
function isStandaloneDocumentsPathText(node: ReactNode): string | null {
if (typeof node !== "string") return null;
const value = node.trim();
if (!value.startsWith("/documents/")) return null;
if (value.includes(" ")) return null;
const normalized = value.replace(/\/+$/, "");
const leaf = normalized.split("/").filter(Boolean).at(-1) ?? "";
if (!leaf || !leaf.includes(".")) return null;
return value;
}
function FilePathLink({ path, className }: { path: string; className?: string }) {
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const electronAPI = useElectronAPI();
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
? Number(searchSpaceIdParam[0])
: Number(searchSpaceIdParam);
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId
: undefined;
return (
<button
type="button"
className={cn(
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80",
className
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void (async () => {
if (electronAPI) {
let resolvedLocalPath = path;
if (electronAPI.getAgentFilesystemMounts) {
try {
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(path, mounts);
} catch {
// Fall back to the raw path if mount lookup fails.
}
}
openEditorPanel({
kind: "local_file",
localFilePath: resolvedLocalPath,
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
searchSpaceId: resolvedSearchSpaceId,
});
return;
}
if (!resolvedSearchSpaceId || !path.startsWith("/documents/")) return;
try {
const doc = await documentsApiService.getDocumentByVirtualPath({
search_space_id: resolvedSearchSpaceId,
virtual_path: path,
});
openEditorPanel({
kind: "document",
documentId: doc.id,
searchSpaceId: resolvedSearchSpaceId,
title: doc.title,
});
} catch {
toast.error("Document not found in knowledge base.");
}
})();
}}
title="Open in editor panel"
>
{path}
</button>
);
}
function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
if (!src) return null;
@ -322,92 +315,127 @@ function MarkdownImage({ src, alt }: { src?: string; alt?: string }) {
}
const defaultComponents = memoizeMarkdownComponents({
h1: ({ className, children, ...props }) => (
<h1
className={cn(
"aui-md-h1 mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h1>
),
h2: ({ className, children, ...props }) => (
<h2
className={cn(
"aui-md-h2 mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h2>
),
h3: ({ className, children, ...props }) => (
<h3
className={cn(
"aui-md-h3 mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h3>
),
h4: ({ className, children, ...props }) => (
<h4
className={cn(
"aui-md-h4 mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</h4>
),
h5: ({ className, children, ...props }) => (
<h5
className={cn("aui-md-h5 my-4 font-semibold text-lg first:mt-0 last:mb-0", className)}
{...props}
>
{processChildrenWithCitations(children)}
</h5>
),
h6: ({ className, children, ...props }) => (
<h6 className={cn("aui-md-h6 my-4 font-semibold first:mt-0 last:mb-0", className)} {...props}>
{processChildrenWithCitations(children)}
</h6>
),
p: ({ className, children, ...props }) => (
<p className={cn("aui-md-p mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)} {...props}>
{processChildrenWithCitations(children)}
</p>
),
a: ({ className, children, ...props }) => (
<a
className={cn("aui-md-a font-medium text-primary underline underline-offset-4", className)}
{...props}
>
{processChildrenWithCitations(children)}
</a>
),
blockquote: ({ className, children, ...props }) => (
<blockquote className={cn("aui-md-blockquote border-l-2 pl-6 italic", className)} {...props}>
{processChildrenWithCitations(children)}
</blockquote>
),
h1: function H1({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<h1
className={cn(
"aui-md-h1 mb-8 scroll-m-20 font-extrabold text-4xl tracking-tight last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</h1>
);
},
h2: function H2({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<h2
className={cn(
"aui-md-h2 mt-8 mb-4 scroll-m-20 font-semibold text-3xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</h2>
);
},
h3: function H3({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<h3
className={cn(
"aui-md-h3 mt-6 mb-4 scroll-m-20 font-semibold text-2xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</h3>
);
},
h4: function H4({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<h4
className={cn(
"aui-md-h4 mt-6 mb-4 scroll-m-20 font-semibold text-xl tracking-tight first:mt-0 last:mb-0",
className
)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</h4>
);
},
h5: function H5({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<h5
className={cn("aui-md-h5 my-4 font-semibold text-lg first:mt-0 last:mb-0", className)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</h5>
);
},
h6: function H6({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<h6 className={cn("aui-md-h6 my-4 font-semibold first:mt-0 last:mb-0", className)} {...props}>
{processChildrenWithCitations(children, urlMap)}
</h6>
);
},
p: function P({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
const standalonePath = isStandaloneDocumentsPathText(children);
return (
<p className={cn("aui-md-p mt-5 mb-5 leading-7 first:mt-0 last:mb-0", className)} {...props}>
{standalonePath ? (
<FilePathLink path={standalonePath} />
) : (
processChildrenWithCitations(children, urlMap)
)}
</p>
);
},
a: function A({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<a
className={cn("aui-md-a font-medium text-primary underline underline-offset-4", className)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</a>
);
},
blockquote: function Blockquote({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<blockquote className={cn("aui-md-blockquote border-l-2 pl-6 italic", className)} {...props}>
{processChildrenWithCitations(children, urlMap)}
</blockquote>
);
},
ul: ({ className, ...props }) => (
<ul className={cn("aui-md-ul my-5 ml-6 list-disc [&>li]:mt-2", className)} {...props} />
),
ol: ({ className, ...props }) => (
<ol className={cn("aui-md-ol my-5 ml-6 list-decimal [&>li]:mt-2", className)} {...props} />
),
li: ({ className, children, ...props }) => (
<li className={cn("aui-md-li", className)} {...props}>
{processChildrenWithCitations(children)}
</li>
),
li: function Li({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<li className={cn("aui-md-li", className)} {...props}>
{processChildrenWithCitations(children, urlMap)}
</li>
);
},
hr: ({ className, ...props }) => (
<hr className={cn("aui-md-hr my-5 border-b", className)} {...props} />
),
@ -422,28 +450,34 @@ const defaultComponents = memoizeMarkdownComponents({
tbody: ({ className, ...props }) => (
<TableBody className={cn("aui-md-tbody", className)} {...props} />
),
th: ({ className, children, ...props }) => (
<TableHead
className={cn(
"aui-md-th bg-muted/50 whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</TableHead>
),
td: ({ className, children, ...props }) => (
<TableCell
className={cn(
"aui-md-td whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
className
)}
{...props}
>
{processChildrenWithCitations(children)}
</TableCell>
),
th: function Th({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<TableHead
className={cn(
"aui-md-th bg-muted/50 whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
className
)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</TableHead>
);
},
td: function Td({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<TableCell
className={cn(
"aui-md-td whitespace-normal [[align=center]]:text-center [[align=right]]:text-right",
className
)}
{...props}
>
{processChildrenWithCitations(children, urlMap)}
</TableCell>
);
},
tr: ({ className, ...props }) => <TableRow className={cn("aui-md-tr", className)} {...props} />,
sup: ({ className, ...props }) => (
<sup className={cn("aui-md-sup [&>a]:text-xs [&>a]:no-underline", className)} {...props} />
@ -452,8 +486,6 @@ const defaultComponents = memoizeMarkdownComponents({
code: function Code({ className, children, ...props }) {
const isCodeBlock = useIsMarkdownCodeBlock();
const { resolvedTheme } = useTheme();
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const electronAPI = useElectronAPI();
const language = /language-(\w+)/.exec(className || "")?.[1] ?? "text";
const codeString = String(children).replace(/\n$/, "");
@ -470,53 +502,17 @@ const defaultComponents = memoizeMarkdownComponents({
const isLikelyFolder =
inlineValue.endsWith("/") || !leafSegment || !leafSegment.includes(".");
const isLocalPath =
!!electronAPI &&
isVirtualFilePathToken(inlineValue) &&
!inlineValue.startsWith("//") &&
!isLikelyFolder;
const displayLocalPath = inlineValue.replace(/^\/+/, "");
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
? Number(searchSpaceIdParam[0])
: Number(searchSpaceIdParam);
(isVirtualFilePathToken(inlineValue) &&
!inlineValue.startsWith("//") &&
!isLikelyFolder &&
!!electronAPI) ||
(isVirtualFilePathToken(inlineValue) &&
!inlineValue.startsWith("//") &&
!isLikelyFolder &&
!electronAPI &&
inlineValue.startsWith("/documents/"));
if (isLocalPath) {
return (
<button
type="button"
className={cn(
"cursor-pointer font-mono text-[0.9em] font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
)}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void (async () => {
let resolvedLocalPath = inlineValue;
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId
: undefined;
if (electronAPI?.getAgentFilesystemMounts) {
try {
const mounts = (await electronAPI.getAgentFilesystemMounts(
resolvedSearchSpaceId
)) as AgentFilesystemMount[];
resolvedLocalPath = normalizeLocalVirtualPathForEditor(inlineValue, mounts);
} catch {
// Fall back to the raw inline path if mount lookup fails.
}
}
openEditorPanel({
kind: "local_file",
localFilePath: resolvedLocalPath,
title: resolvedLocalPath.split("/").pop() || resolvedLocalPath,
searchSpaceId: resolvedSearchSpaceId,
});
})();
}}
title="Open in editor panel"
>
{displayLocalPath}
</button>
);
return <FilePathLink path={inlineValue} className="text-[0.9em]" />;
}
return (
<code
@ -552,16 +548,22 @@ const defaultComponents = memoizeMarkdownComponents({
/>
);
},
strong: ({ className, children, ...props }) => (
<strong className={cn("aui-md-strong font-semibold", className)} {...props}>
{processChildrenWithCitations(children)}
</strong>
),
em: ({ className, children, ...props }) => (
<em className={cn("aui-md-em", className)} {...props}>
{processChildrenWithCitations(children)}
</em>
),
strong: function Strong({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<strong className={cn("aui-md-strong font-semibold", className)} {...props}>
{processChildrenWithCitations(children, urlMap)}
</strong>
);
},
em: function Em({ className, children, ...props }) {
const urlMap = useCitationUrlMap();
return (
<em className={cn("aui-md-em", className)} {...props}>
{processChildrenWithCitations(children, urlMap)}
</em>
);
},
img: ({ src, alt }) => (
<MarkdownImage src={typeof src === "string" ? src : undefined} alt={alt} />
),

View file

@ -0,0 +1,24 @@
"use client";
import { type ComponentPropsWithoutRef, forwardRef, type WheelEvent } from "react";
export type NestedScrollProps = ComponentPropsWithoutRef<"div">;
export const NestedScroll = forwardRef<HTMLDivElement, NestedScrollProps>(
({ onWheel, ...props }, ref) => {
const handleWheel = (event: WheelEvent<HTMLDivElement>) => {
const el = event.currentTarget;
const canScrollUp = el.scrollTop > 0;
const canScrollDown = el.scrollTop < el.scrollHeight - el.clientHeight - 1;
const goingUp = event.deltaY < 0;
const goingDown = event.deltaY > 0;
if ((goingUp && canScrollUp) || (goingDown && canScrollDown)) {
event.stopPropagation();
}
onWheel?.(event);
};
return <div ref={ref} onWheel={handleWheel} {...props} />;
}
);
NestedScroll.displayName = "NestedScroll";

View file

@ -0,0 +1,81 @@
"use client";
import type { ReasoningMessagePartComponent } from "@assistant-ui/react";
import { ChevronRightIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { cn } from "@/lib/utils";
/**
* Renders the structured `reasoning` part emitted by the backend's
* stream-parity v2 path (A1).
*
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
* - collapsed by default;
* - auto-expanded while the part is still `running`;
* - auto-collapsed once status flips to `complete`.
*
* The component is registered via the `Reasoning` slot on
* `MessagePrimitive.Parts` in `assistant-message.tsx` so it lives at the
* exact ordinal position of the reasoning block in the message content
* array (i.e. above the assistant text that follows it).
*/
export const ReasoningMessagePart: ReasoningMessagePartComponent = ({ text, status }) => {
const isRunning = status?.type === "running";
const [isOpen, setIsOpen] = useState(() => isRunning);
useEffect(() => {
if (isRunning) {
setIsOpen(true);
} else if (status?.type === "complete") {
setIsOpen(false);
}
}, [isRunning, status?.type]);
const headerLabel = useMemo(() => {
if (isRunning) return "Thinking";
if (status?.type === "incomplete") return "Thinking interrupted";
return "Thought";
}, [isRunning, status?.type]);
if (!text || text.length === 0) {
if (!isRunning) return null;
}
return (
<div className="mx-auto w-full max-w-(--thread-max-width) px-2 py-2">
<div className="rounded-lg">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
"flex w-full items-center gap-1.5 text-left text-sm transition-colors",
"text-muted-foreground hover:text-foreground"
)}
>
{isRunning ? (
<TextShimmerLoader text={headerLabel} size="sm" />
) : (
<span>{headerLabel}</span>
)}
<ChevronRightIcon
className={cn("size-4 transition-transform duration-200", isOpen && "rotate-90")}
/>
</button>
<div
className={cn(
"grid transition-[grid-template-rows] duration-300 ease-out",
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
)}
>
<div className="overflow-hidden">
<div className="mt-2 border-l border-muted-foreground/30 pl-3 text-sm leading-relaxed text-muted-foreground whitespace-pre-wrap wrap-break-word">
{text}
</div>
</div>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,213 @@
"use client";
/**
* "Revert turn" button rendered at the bottom of every completed
* assistant turn that has at least one reversible action.
*
* The button reads from the unified ``useAgentActionsQuery`` cache
* (the SAME react-query cache the agent-actions sheet and the inline
* Revert button consume) filtered by ``chat_turn_id``. It shows a
* confirmation dialog summarising "N reversible / M total" and, on
* confirm, calls ``POST /threads/{id}/revert-turn/{chat_turn_id}``.
*
* The route returns a per-action result list and never collapses the
* batch into a 4xx so we render any failed/not_reversible rows inline
* with their messages.
*/
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { CheckIcon, RotateCcw, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import {
applyRevertTurnResultsToCache,
useAgentActionsQuery,
} from "@/hooks/use-agent-actions-query";
import {
agentActionsApiService,
type RevertTurnActionResult,
} from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { cn } from "@/lib/utils";
interface RevertTurnButtonProps {
chatTurnId: string | null | undefined;
}
export function RevertTurnButton({ chatTurnId }: RevertTurnButtonProps) {
const session = useAtomValue(chatSessionStateAtom);
const threadId = session?.threadId ?? null;
const queryClient = useQueryClient();
const { findByChatTurnId } = useAgentActionsQuery(threadId);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [resultsOpen, setResultsOpen] = useState(false);
const [results, setResults] = useState<RevertTurnActionResult[]>([]);
const actions = useMemo(() => findByChatTurnId(chatTurnId), [findByChatTurnId, chatTurnId]);
const reversibleCount = useMemo(
() =>
actions.filter(
(a) =>
a.reversible &&
(a.reverted_by_action_id === null || a.reverted_by_action_id === undefined) &&
!a.is_revert_action &&
(a.error === null || a.error === undefined)
).length,
[actions]
);
const totalCount = useMemo(() => actions.filter((a) => !a.is_revert_action).length, [actions]);
if (!chatTurnId) return null;
if (reversibleCount === 0) return null;
if (!threadId) return null;
const handleRevertTurn = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revertTurn(threadId, chatTurnId);
setResults(response.results);
const revertedEntries = response.results
.filter((r) => r.status === "reverted" || r.status === "already_reverted")
.map((r) => ({ id: r.action_id, newActionId: r.new_action_id ?? null }));
if (revertedEntries.length > 0) {
applyRevertTurnResultsToCache(queryClient, threadId, revertedEntries);
}
if (response.status === "ok") {
toast.success(
response.reverted === 1 ? "Reverted 1 action." : `Reverted ${response.reverted} actions.`
);
} else {
// Every "not undone" bucket counts as a failure for the
// user-facing summary. ``skipped`` rows are batch
// artefacts (revert rows themselves) and intentionally
// excluded from the failure tally.
const failureCount =
response.failed + response.not_reversible + (response.permission_denied ?? 0);
toast.warning(
`Reverted ${response.reverted} of ${response.total}. ${failureCount} could not be undone.`
);
setResultsOpen(true);
}
} catch (err) {
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert turn.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-foreground gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
>
<RotateCcw className="size-3.5" />
<span>Revert turn</span>
<span className="text-xs tabular-nums opacity-70">
{reversibleCount}/{totalCount}
</span>
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this turn?</AlertDialogTitle>
<AlertDialogDescription>
This will undo {reversibleCount} of {totalCount} action
{totalCount === 1 ? "" : "s"} from this turn in reverse order. The chat history and
any read-only actions are preserved. Some rows may not be reversible partial success
is normal.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevertTurn();
}}
disabled={isReverting}
>
{isReverting ? "Reverting…" : "Revert turn"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={resultsOpen} onOpenChange={setResultsOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert results</AlertDialogTitle>
<AlertDialogDescription>
Some actions could not be reverted. Review per-row outcomes below.
</AlertDialogDescription>
</AlertDialogHeader>
<ul className="max-h-72 overflow-y-auto space-y-2 text-sm">
{results.map((r) => (
<RevertResultRow key={r.action_id} result={r} />
))}
</ul>
<AlertDialogFooter>
<AlertDialogAction onClick={() => setResultsOpen(false)}>Close</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
function RevertResultRow({ result }: { result: RevertTurnActionResult }) {
const isOk = result.status === "reverted" || result.status === "already_reverted";
const Icon = isOk ? CheckIcon : XCircleIcon;
return (
<li className="flex items-start gap-2 rounded-md border bg-muted/30 px-3 py-2">
<Icon
className={cn("size-4 mt-0.5 shrink-0", isOk ? "text-emerald-500" : "text-destructive")}
/>
<div className="min-w-0 flex-1">
<p className="font-medium truncate">
{getToolDisplayName(result.tool_name)}{" "}
<span className="ml-1 text-xs text-muted-foreground">
{result.status.replace(/_/g, " ")}
</span>
</p>
{(result.message || result.error) && (
<p className="text-xs text-muted-foreground mt-0.5">{result.error ?? result.message}</p>
)}
</div>
</li>
);
}

View file

@ -0,0 +1,27 @@
"use client";
import { makeAssistantDataUI } from "@assistant-ui/react";
/**
* Renders a thin horizontal divider between model steps within a single
* assistant turn. The data part is pushed by `addStepSeparator` in
* `streaming-state.ts` whenever a `start-step` SSE event arrives after
* the message already has non-step content.
*
* Today the backend emits one `start-step` / `finish-step` pair per turn,
* so most messages won't contain a separator. The renderer is wired up so
* the planned per-model-step refactor (A2 follow-up) can light up without
* touching the persistence path.
*/
function StepSeparatorDataRenderer() {
return (
<div className="mx-auto my-3 w-full max-w-(--thread-max-width) px-2">
<div className="border-t border-border/60" />
</div>
);
}
export const StepSeparatorDataUI = makeAssistantDataUI({
name: "step-separator",
render: StepSeparatorDataRenderer,
});

View file

@ -1,18 +0,0 @@
import { ThreadPrimitive } from "@assistant-ui/react";
import { ArrowDownIcon } from "lucide-react";
import type { FC } from "react";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
export const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};

View file

@ -5,12 +5,10 @@ import {
ThreadPrimitive,
useAui,
useAuiState,
useThreadViewportStore,
} from "@assistant-ui/react";
import { useAtom, useAtomValue, useSetAtom } from "jotai";
import {
AlertCircle,
ArrowDownIcon,
ArrowUpIcon,
Camera,
ChevronDown,
@ -37,10 +35,13 @@ import {
toggleToolAtom,
} from "@/atoms/agent-tools/agent-tools.atoms";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import {
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
clearPremiumAlertForThreadAtom,
premiumAlertByThreadAtom,
} from "@/atoms/chat/premium-alert.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { membersAtom } from "@/atoms/members/members-query.atoms";
@ -52,6 +53,7 @@ import {
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatSessionStatus } from "@/components/assistant-ui/chat-session-status";
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
import { ConnectorIndicator } from "@/components/assistant-ui/connector-popup";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
import {
@ -82,6 +84,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import {
CONNECTOR_ICON_TO_TYPES,
CONNECTOR_TOOL_ICON_PATHS,
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types";
@ -89,8 +92,8 @@ import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events";
import { cn } from "@/lib/utils";
@ -108,10 +111,13 @@ const ThreadContent: FC = () => {
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
style={{ scrollbarGutter: "stable" }}
<ChatViewport
footer={
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<PremiumQuotaPinnedAlert />
<Composer />
</AuiIf>
}
>
<AuiIf condition={({ thread }) => thread.isEmpty}>
<ThreadWelcome />
@ -124,36 +130,39 @@ const ThreadContent: FC = () => {
AssistantMessage,
}}
/>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="grow" />
</AuiIf>
<ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-2xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<Composer />
</AuiIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ChatViewport>
</ThreadPrimitive.Root>
);
};
const ThreadScrollToBottom: FC = () => {
const PremiumQuotaPinnedAlert: FC = () => {
const currentThreadState = useAtomValue(currentThreadAtom);
const alertsByThread = useAtomValue(premiumAlertByThreadAtom);
const clearPremiumAlertForThread = useSetAtom(clearPremiumAlertForThreadAtom);
const currentThreadId = currentThreadState?.id;
if (!currentThreadId) return null;
const alert = alertsByThread[currentThreadId];
if (!alert) return null;
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
<div className="mx-0 overflow-hidden rounded-2xl border-input bg-muted px-4 py-4 text-foreground select-none">
<div className="flex items-center gap-2">
<AlertCircle className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="text-sm">{alert.message}</p>
</div>
<button
type="button"
className="inline-flex size-6 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
aria-label="Dismiss premium quota alert"
onClick={() => clearPremiumAlertForThread(currentThreadId)}
>
<X className="size-4" />
</button>
</div>
</div>
);
};
@ -373,23 +382,9 @@ const Composer: FC = () => {
>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const viewportRef = useRef<Element | null>(null);
const { search_space_id, chat_id } = useParams();
const aui = useAui();
const threadViewportStore = useThreadViewportStore();
const hasAutoFocusedRef = useRef(false);
const submitCleanupRef = useRef<(() => void) | null>(null);
useEffect(() => {
return () => {
submitCleanupRef.current?.();
};
}, []);
// Store viewport element reference on mount
useEffect(() => {
viewportRef.current = document.querySelector(".aui-thread-viewport");
}, []);
const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
@ -588,7 +583,6 @@ const Composer: FC = () => {
[showDocumentPopover, showPromptPicker]
);
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
if (isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover || showPromptPicker) return;
@ -600,50 +594,9 @@ const Composer: FC = () => {
setClipboardInitialText(undefined);
}
const viewportEl = viewportRef.current;
const heightBefore = viewportEl?.scrollHeight ?? 0;
aui.composer().send();
editorRef.current?.clear();
setMentionedDocuments([]);
// With turnAnchor="top", ViewportSlack adds min-height to the last
// assistant message so that scrolling-to-bottom actually positions the
// user message at the TOP of the viewport. That slack height is
// calculated asynchronously (ResizeObserver → style → layout).
// Poll via rAF for ~500ms, re-scrolling whenever scrollHeight changes.
const scrollToBottom = () =>
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
let lastHeight = heightBefore;
let frames = 0;
let cancelled = false;
const POLL_FRAMES = 30;
const pollAndScroll = () => {
if (cancelled) return;
const el = viewportRef.current;
if (el) {
const h = el.scrollHeight;
if (h !== lastHeight) {
lastHeight = h;
scrollToBottom();
}
}
if (++frames < POLL_FRAMES) {
requestAnimationFrame(pollAndScroll);
}
};
requestAnimationFrame(pollAndScroll);
const t1 = setTimeout(scrollToBottom, 100);
const t2 = setTimeout(scrollToBottom, 300);
submitCleanupRef.current = () => {
cancelled = true;
clearTimeout(t1);
clearTimeout(t2);
};
}, [
showDocumentPopover,
showPromptPicker,
@ -652,7 +605,6 @@ const Composer: FC = () => {
clipboardInitialText,
aui,
setMentionedDocuments,
threadViewportStore,
]);
const handleDocumentRemove = useCallback(
@ -1317,12 +1269,14 @@ const ComposerAction: FC<ComposerActionProps> = ({ isBlockedByOtherUser = false
);
};
/** Convert snake_case tool names to human-readable labels */
/**
* Friendly tool name for display in the chat UI. Delegates to the
* shared map in ``contracts/enums/toolIcons`` so unix-style identifiers
* (``rm``, ``ls``, ``grep`` ) and snake_cased function names render as
* plain English (e.g. "Delete file", "List files", "Search in files").
*/
function formatToolName(name: string): string {
return name
.split("_")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
return getToolDisplayName(name);
}
interface ToolGroup {

View file

@ -1,30 +1,277 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { type ToolCallMessagePartComponent, useAuiState } from "@assistant-ui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { CheckIcon, ChevronDownIcon, RotateCcw, XCircleIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { NestedScroll } from "@/components/assistant-ui/nested-scroll";
import {
DoomLoopApprovalToolUI,
isDoomLoopInterrupt,
} from "@/components/tool-ui/doom-loop-approval";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { Spinner } from "@/components/ui/spinner";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { markActionRevertedInCache, useAgentActionsQuery } from "@/hooks/use-agent-actions-query";
import { agentActionsApiService } from "@/lib/apis/agent-actions-api.service";
import { AppError } from "@/lib/error";
import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils";
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
/**
* Inline Revert button rendered on a tool card when the matching
* ``AgentActionLog`` row is reversible and hasn't been reverted yet.
*
* Reads from the unified ``useAgentActionsQuery`` cache the SAME
* react-query cache the agent-actions sheet consumes. SSE events
* (``data-action-log`` / ``data-action-log-updated``) and
* ``POST /threads/{id}/revert/{id}`` responses both flow through the
* cache via ``setQueryData`` helpers, so the card and the sheet stay
* in lockstep on every code path: page reload, navigation, live
* stream, post-stream reversibility flip, and explicit revert clicks.
*
* Match key (in priority order):
* 1. ``a.tool_call_id === toolCallId`` direct hit in parity_v2 when
* the model streamed ``tool_call_chunks`` so the card's synthetic
* id IS the LangChain id.
* 2. ``a.tool_call_id === langchainToolCallId`` legacy mode (or
* parity_v2 with provider-side chunk emission) where the card's
* synthetic id is ``call_<run_id>`` and the LangChain id is
* backfilled onto the part by ``tool-output-available``.
* 3. ``(chat_turn_id, tool_name, position-within-turn)`` fallback
* for cards whose synthetic id is ``call_<run_id>`` AND whose
* ``langchainToolCallId`` never got backfilled (provider emitted
* the tool_call as a single payload with no chunks AND streaming
* pre-dated the ``tool-output-available langchainToolCallId``
* backfill, e.g. older threads). Reads the parent message's
* ``chatTurnId`` and ``content`` via ``useAuiState`` so we can
* match position-by-tool-name within the turn against the
* action_log rows the server returned in ``created_at`` order.
*/
function ToolCardRevertButton({
toolCallId,
toolName,
langchainToolCallId,
}: {
toolCallId: string;
toolName: string;
langchainToolCallId?: string;
}) {
const session = useAtomValue(chatSessionStateAtom);
const threadId = session?.threadId ?? null;
const queryClient = useQueryClient();
const { findByToolCallId, findByChatTurnAndTool } = useAgentActionsQuery(threadId);
// Parent message metadata, read via the narrowest possible
// selectors so this card doesn't re-render on every text-delta of
// every other part in the same message during streaming.
//
// IMPORTANT — ``useAuiState`` re-renders the component whenever the
// returned slice's identity changes. Returning ``message?.content``
// (an array) would re-render on every token because the runtime
// rebuilds the parts array. Returning a PRIMITIVE (the position
// number) lets ``useAuiState``'s ``Object.is`` check short-circuit
// when the position hasn't actually moved — which is the common
// case during text streaming, when only ``text``/``reasoning``
// parts are mutating and the same-toolName tool-call ordering is
// stable. (See Vercel React rule ``rerender-defer-reads``.)
const chatTurnId = useAuiState(({ message }) => {
const meta = message?.metadata as { custom?: { chatTurnId?: string } } | undefined;
return meta?.custom?.chatTurnId ?? null;
});
const positionInTurn = useAuiState(({ message }) => {
const content = message?.content;
if (!Array.isArray(content)) return -1;
let n = -1;
for (const part of content) {
if (
part &&
typeof part === "object" &&
(part as { type?: string }).type === "tool-call" &&
(part as { toolName?: string }).toolName === toolName
) {
n += 1;
if ((part as { toolCallId?: string }).toolCallId === toolCallId) return n;
}
}
return -1;
});
const action = useMemo(() => {
// Tier 1 + 2: O(1) Map-backed direct id match. Covers
// ~all parity_v2 streams and any legacy stream that backfilled
// ``langchainToolCallId`` via ``tool-output-available``.
const direct = findByToolCallId(toolCallId) ?? findByToolCallId(langchainToolCallId);
if (direct) return direct;
// Tier 3: position-within-turn fallback. Only kicks in when the
// card has a synthetic ``call_<run_id>`` id AND no
// ``langchainToolCallId`` was ever backfilled — i.e. the tool
// was emitted as a single non-chunked payload AND streaming
// pre-dated the on_tool_end backfill.
if (!chatTurnId || positionInTurn < 0) return null;
const turnSameTool = findByChatTurnAndTool(chatTurnId, toolName);
return turnSameTool[positionInTurn] ?? null;
}, [
findByToolCallId,
findByChatTurnAndTool,
toolCallId,
langchainToolCallId,
chatTurnId,
toolName,
positionInTurn,
]);
const [isReverting, setIsReverting] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
if (!action) return null;
if (!action.reversible) return null;
if (action.reverted_by_action_id !== null && action.reverted_by_action_id !== undefined)
return null;
if (action.is_revert_action) return null;
if (action.error !== null && action.error !== undefined) return null;
if (!threadId) return null;
const handleRevert = async () => {
setIsReverting(true);
try {
const response = await agentActionsApiService.revert(threadId, action.id);
markActionRevertedInCache(queryClient, threadId, action.id, response.new_action_id ?? null);
toast.success(response.message || "Action reverted.");
} catch (err) {
// 503 means revert is gated off on this deployment — hide the
// button silently rather than nagging the user. Any other error
// is surfaced as a toast so the operator can investigate.
if (err instanceof AppError && err.status === 503) {
return;
}
const message =
err instanceof AppError
? err.message
: err instanceof Error
? err.message
: "Failed to revert action.";
toast.error(message);
} finally {
setIsReverting(false);
setConfirmOpen(false);
}
};
return (
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogTrigger asChild>
<Button
size="sm"
variant="outline"
className="gap-1.5"
onClick={(e) => {
e.stopPropagation();
setConfirmOpen(true);
}}
disabled={isReverting}
>
{isReverting ? (
// Spinner's typed props don't accept ``data-icon`` and
// it renders an <output>, not an <svg>, so Button's
// auto-sizing rule doesn't apply. Bare spinner +
// Button's gap handle layout.
<Spinner size="xs" />
) : (
<RotateCcw data-icon="inline-start" />
)}
Revert
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Revert this action?</AlertDialogTitle>
<AlertDialogDescription>
This will undo{" "}
<span className="font-medium">{getToolDisplayName(action.tool_name)}</span> and add a
new entry to the history. Your chat is preserved only the changes the agent made to
your knowledge base or connected apps will be rolled back where possible.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isReverting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
handleRevert();
}}
disabled={isReverting}
className="gap-1.5"
>
{isReverting && <Spinner size="xs" />}
Revert
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
status,
}) => {
const [isExpanded, setIsExpanded] = useState(false);
/**
* Compact tool-call card.
*
* shadcn composition note: we intentionally use ``Card`` as a visual
* frame WITHOUT ``CardHeader / CardContent``. The full composition's
* ``p-6`` padding doesn't fit a compact collapsible header that IS the
* trigger; using ``Card`` alone preserves the rounded border, shadow,
* and ``bg-card`` token (semantic colors) without forcing a layout
* that doesn't fit. All status colors use semantic tokens no manual
* dark-mode overrides, no raw hex.
*/
const DefaultToolFallbackInner: ToolCallMessagePartComponent = (props) => {
const { toolCallId, toolName, argsText, result, status } = props;
// ``langchainToolCallId`` is a SurfSense-specific extension the
// streaming pipeline attaches to the tool-call content part so
// the Revert button can resolve its ``AgentActionLog`` row even
// when only the LC id is known. assistant-ui's
// ``ToolCallMessagePartProps`` doesn't list it, but the runtime
// spreads ``{...part}`` so the prop reaches us at runtime.
const langchainToolCallId = (props as { langchainToolCallId?: string }).langchainToolCallId;
const isCancelled = status?.type === "incomplete" && status.reason === "cancelled";
const isError = status?.type === "incomplete" && status.reason === "error";
const isRunning = status?.type === "running" || status?.type === "requires-action";
/*
Per-card expansion state. Initial value is ``isRunning`` so a
card streaming in mounts already-expanded (no flash of
collapsed expanded on first paint), while a card loaded from
history (status="complete") mounts collapsed. The useEffect
below keeps this in lockstep with this card's own ``isRunning``
when it transitions: false true auto-expands (e.g. a tool
that re-runs after edit), true false auto-collapses once the
tool finishes. Because the dep is per-card ``isRunning`` and
not the chat-level streaming flag, sibling cards on the same
assistant turn each manage their own expansion independently.
Once ``isRunning`` is false the user controls expansion via
``onOpenChange``.
*/
const [isExpanded, setIsExpanded] = useState(isRunning);
useEffect(() => {
setIsExpanded(isRunning);
}, [isRunning]);
const errorData = status?.type === "incomplete" ? status.error : undefined;
const serializedError = useMemo(
() => (errorData && typeof errorData !== "string" ? JSON.stringify(errorData) : null),
@ -50,105 +297,207 @@ const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
: serializedError
: null;
const Icon = getToolIcon(toolName);
const displayName = formatToolName(toolName);
const displayName = getToolDisplayName(toolName);
const subtitle = errorReason ?? cancelledReason;
return (
<div
<Card
className={cn(
"my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 select-none",
"my-4 max-w-lg overflow-hidden",
isCancelled && "opacity-60",
isError && "border-destructive/20 bg-destructive/5"
isError && "border-destructive/30"
)}
>
<button
type="button"
onClick={() => setIsExpanded((prev) => !prev)}
className="flex w-full items-center gap-3 px-5 py-4 text-left transition-colors hover:bg-muted/50 focus:outline-none focus-visible:outline-none"
{/*
``group`` lets the chevron (rendered as a sibling of the
main trigger button) read the Collapsible Root's
``data-[state=open]`` for rotation. The Collapsible is
fully controlled via ``isExpanded`` the useEffect
above syncs it to ``isRunning`` so the card auto-opens
while a tool streams in and auto-collapses once it
finishes. We deliberately DON'T pass ``disabled`` so
both triggers stay clickable; ``onOpenChange`` is wired
to a setter that no-ops while ``isRunning`` (see
``handleOpenChange`` below) which keeps the card pinned
open mid-stream without losing keyboard / pointer
affordance the moment streaming ends.
*/}
<Collapsible
className="group"
open={isExpanded}
onOpenChange={(next) => {
// Block manual collapse while the tool is still
// streaming — otherwise a stray click on either
// trigger would close the card and hide the live
// ``argsText`` panel mid-run. After streaming the
// user has full control again.
if (isRunning) return;
setIsExpanded(next);
}}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
)}
>
{isError ? (
<XCircleIcon className="size-4 text-destructive" />
) : isCancelled ? (
<XCircleIcon className="size-4 text-muted-foreground" />
) : isRunning ? (
<Icon className="size-4 text-primary animate-pulse" />
) : (
<CheckIcon className="size-4 text-primary" />
)}
</div>
{/*
Header row: main trigger on the left (icon + title
col), Revert + chevron-trigger on the right as
siblings of the main trigger. The chevron is wrapped
in its OWN ``CollapsibleTrigger`` (Radix supports
multiple triggers per Root) so clicking the chevron
toggles the same state as clicking the title row.
The Revert button stays a separate AlertDialog
trigger and stops propagation in its onClick so it
doesn't toggle the collapsible while opening the
confirm dialog. Keeping these as flat siblings
rather than nesting Revert / chevron inside the
title trigger avoids invalid HTML
(button-in-button) and lets the Revert button
render in BOTH the collapsed and expanded states.
*/}
<div className="flex items-stretch transition-colors hover:bg-muted/50">
<CollapsibleTrigger asChild>
<button
type="button"
className={cn(
"flex flex-1 min-w-0 items-center gap-3 py-4 pl-5 pr-2 text-left",
// Inset ring — Card's ``overflow-hidden`` would
// clip an ``offset-2`` ring; ``ring-inset``
// paints inside the button box.
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<div
className={cn(
"flex size-8 shrink-0 items-center justify-center rounded-lg",
isError ? "bg-destructive/10" : isCancelled ? "bg-muted" : "bg-primary/10"
)}
>
{isError ? (
<XCircleIcon className="size-4 text-destructive" />
) : isCancelled ? (
<XCircleIcon className="size-4 text-muted-foreground" />
) : isRunning ? (
<Spinner size="sm" className="text-primary" />
) : (
<CheckIcon className="size-4 text-primary" />
)}
</div>
<div className="flex-1 min-w-0">
<p
className={cn(
"text-sm font-semibold",
isError
? "text-destructive"
: isCancelled
? "text-muted-foreground line-through"
: "text-foreground"
)}
>
{isRunning
? displayName
: isCancelled
? `Cancelled: ${displayName}`
: isError
? `Failed: ${displayName}`
: displayName}
</p>
{isRunning && <p className="text-xs text-muted-foreground mt-0.5">Running...</p>}
{cancelledReason && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">{cancelledReason}</p>
)}
{errorReason && (
<p className="text-xs text-destructive/80 mt-0.5 truncate">{errorReason}</p>
)}
</div>
<div className="flex flex-1 min-w-0 flex-col gap-0.5">
<div className="flex items-center gap-2">
<p
className={cn(
"text-sm font-semibold truncate",
isCancelled && "text-muted-foreground line-through",
isError && "text-destructive"
)}
>
{displayName}
</p>
{isRunning && <Badge variant="secondary">Running</Badge>}
{isError && <Badge variant="destructive">Failed</Badge>}
{isCancelled && <Badge variant="outline">Cancelled</Badge>}
</div>
{subtitle && (
<p
className={cn(
"text-xs truncate",
isError ? "text-destructive/80" : "text-muted-foreground"
)}
>
{subtitle}
</p>
)}
</div>
</button>
</CollapsibleTrigger>
{!isRunning && (
<div className="shrink-0 text-muted-foreground">
{isExpanded ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronUpIcon className="size-4" />
)}
{/*
Right-side controls. The Revert button is
visible whenever the matching action is
reversible including the collapsed state
but ``ToolCardRevertButton`` itself returns
``null`` while a tool is still running because
no action-log row exists yet, so it doesn't
need an explicit ``isRunning`` gate here.
*/}
<div className="flex shrink-0 items-center gap-2 pl-2 pr-5">
<ToolCardRevertButton
toolCallId={toolCallId}
toolName={toolName}
langchainToolCallId={langchainToolCallId}
/>
<CollapsibleTrigger asChild>
<button
type="button"
aria-label={isExpanded ? "Collapse details" : "Expand details"}
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md",
"text-muted-foreground hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
"disabled:cursor-default"
)}
>
<ChevronDownIcon
className={cn(
"size-4 transition-transform duration-200",
"group-data-[state=open]:rotate-180"
)}
/>
</button>
</CollapsibleTrigger>
</div>
)}
</button>
</div>
{isExpanded && !isRunning && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3 space-y-3">
{argsText && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Arguments</p>
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
{argsText}
</pre>
{/*
CollapsibleContent body auto-open while streaming
(see ``open`` prop above) so the live ``argsText``
streams into the Inputs panel directly, no need for
a separate "Live input" panel. Native
``overflow-auto`` instead of ``ScrollArea`` because
Radix's Viewport can let content bleed past
``max-h-*`` in dynamic flex layouts. ``min-w-0`` on
the column wrappers guarantees ``break-all`` wraps
correctly within the bounded ``max-w-lg`` Card.
*/}
<CollapsibleContent>
<Separator />
<div className="flex flex-col gap-3 px-5 py-3">
{(argsText || isRunning) && (
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
<NestedScroll className="max-h-48 overflow-auto rounded-md bg-muted/40">
{argsText ? (
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{argsText}
</pre>
) : (
// Bridges the brief gap between
// ``tool-input-start`` (creates the
// card, ``argsText`` undefined) and
// the first ``tool-input-delta``.
<p className="px-3 py-2 text-xs italic text-muted-foreground">
Waiting for input
</p>
)}
</NestedScroll>
</div>
)}
{!isCancelled && result !== undefined && (
<>
<div className="h-px bg-border/30" />
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Result</p>
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all">
{typeof result === "string" ? result : serializedResult}
</pre>
<Separator />
<div className="flex flex-col gap-1 min-w-0">
<p className="text-xs font-medium text-muted-foreground">Result</p>
<NestedScroll className="max-h-64 overflow-auto rounded-md bg-muted/40">
<pre className="px-3 py-2 text-xs text-foreground/80 whitespace-pre-wrap break-all font-mono">
{typeof result === "string" ? result : serializedResult}
</pre>
</NestedScroll>
</div>
</>
)}
</div>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
</Card>
);
};

View file

@ -1,4 +1,10 @@
import { ActionBarPrimitive, AuiIf, MessagePrimitive, useAuiState } from "@assistant-ui/react";
import {
ActionBarPrimitive,
AuiIf,
MessagePrimitive,
useAuiState,
useMessagePartText,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, Pencil } from "lucide-react";
import Image from "next/image";
@ -7,6 +13,8 @@ import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { parseMentionSegments } from "@/lib/chat/parse-mention-segments";
interface AuthorMetadata {
displayName: string | null;
@ -47,23 +55,40 @@ const UserAvatar: FC<AuthorMetadata> = ({ displayName, avatarUrl }) => {
);
};
export const UserMessage: FC = () => {
const UserTextPart: FC = () => {
const messageId = useAuiState(({ message }) => message?.id);
const messageText = useAuiState(({ message }) =>
(message?.content ?? [])
.map((part) =>
typeof part === "object" &&
part !== null &&
"type" in part &&
(part as { type?: string }).type === "text" &&
"text" in part
? String((part as { text?: string }).text ?? "")
: ""
)
.join("")
);
const part = useMessagePartText();
const text = (part as { text?: string }).text ?? "";
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = messageId ? messageDocumentsMap[messageId] : undefined;
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
const segments = parseMentionSegments(text, mentionedDocs);
return (
<p style={{ whiteSpace: "pre-line" }} className="break-words">
{segments.map((segment) =>
segment.type === "text" ? (
<span key={`txt-${segment.start}`}>{segment.value}</span>
) : (
<span
key={`mention-${getMentionDocKey(segment.doc)}-${segment.start}`}
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-middle leading-none"
title={segment.doc.title}
>
<span className="flex items-center text-muted-foreground">
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
</span>
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
</span>
)
)}
</p>
);
};
const userMessageParts = { Text: UserTextPart };
export const UserMessage: FC = () => {
const metadata = useAuiState(({ message }) => message?.metadata);
const author = metadata?.custom?.author as AuthorMetadata | undefined;
const isSharedChat = useAtomValue(currentThreadAtom).visibility === "SEARCH_SPACE";
@ -77,12 +102,8 @@ export const UserMessage: FC = () => {
<div className="col-start-2 min-w-0">
<div className="aui-user-message-content-wrapper flex items-end gap-2">
<div className="relative flex-1 min-w-0">
<div className="aui-user-message-content wrap-break-word rounded-xl bg-muted px-4 py-2.5 text-foreground">
{mentionedDocs && mentionedDocs.length > 0 ? (
<UserMessageWithMentionChips text={messageText} mentionedDocs={mentionedDocs} />
) : (
<MessagePrimitive.Parts />
)}
<div className="aui-user-message-content wrap-break-word rounded-2xl bg-muted px-4 py-2.5 text-foreground">
<MessagePrimitive.Parts components={userMessageParts} />
</div>
<div className="absolute right-0 top-full mt-1 z-10 opacity-100 pointer-events-auto md:opacity-0 md:pointer-events-none md:transition-opacity md:duration-200 md:delay-300 md:group-hover/user-msg:opacity-100 md:group-hover/user-msg:delay-0 md:group-hover/user-msg:pointer-events-auto">
<UserActionBar />
@ -99,64 +120,6 @@ export const UserMessage: FC = () => {
);
};
const UserMessageWithMentionChips: FC<{
text: string;
mentionedDocs: { id: number; title: string; document_type: string }[];
}> = ({ text, mentionedDocs }) => {
type Segment =
| { type: "text"; value: string; start: number }
| { type: "mention"; doc: { id: number; title: string; document_type: string }; start: number };
const tokens = mentionedDocs
.map((doc) => ({ doc, token: `@${doc.title}` }))
.sort((a, b) => b.token.length - a.token.length);
const segments: Segment[] = [];
let i = 0;
let buffer = "";
let bufferStart = 0;
while (i < text.length) {
const tokenMatch = tokens.find(({ token }) => text.startsWith(token, i));
if (tokenMatch) {
if (buffer) {
segments.push({ type: "text", value: buffer, start: bufferStart });
buffer = "";
}
segments.push({ type: "mention", doc: tokenMatch.doc, start: i });
i += tokenMatch.token.length;
bufferStart = i;
continue;
}
if (!buffer) bufferStart = i;
buffer += text[i];
i += 1;
}
if (buffer) {
segments.push({ type: "text", value: buffer, start: bufferStart });
}
return (
<span className="whitespace-pre-wrap break-words">
{segments.map((segment) =>
segment.type === "text" ? (
<span key={`txt-${segment.start}`}>{segment.value}</span>
) : (
<span
key={`mention-${segment.doc.document_type}:${segment.doc.id}-${segment.start}`}
className="inline-flex items-center gap-1 mx-0.5 px-1 py-0.5 rounded bg-primary/10 text-xs font-bold text-primary/60 select-none align-baseline"
title={segment.doc.title}
>
<span className="flex items-center text-muted-foreground">
{getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "h-3 w-3")}
</span>
<span className="max-w-[120px] truncate">{segment.doc.title}</span>
</span>
)
)}
</span>
);
};
const UserActionBar: FC = () => {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);

View file

@ -169,7 +169,7 @@ export const CitationPanelContent: FC<CitationPanelContentProps> = ({ chunkId, o
)}
</div>
<div className="text-sm">
<MarkdownViewer content={chunk.content} />
<MarkdownViewer content={chunk.content} enableCitations />
</div>
</div>
);

View file

@ -0,0 +1,77 @@
"use client";
import type { ReactNode } from "react";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import {
type CitationToken,
type CitationUrlMap,
parseTextWithCitations,
} from "@/lib/citations/citation-parser";
/**
* Render a single parsed citation token as JSX.
*
* `ordinalKey` should be a stable per-render counter so duplicate identical
* citations within the same parent don't collide on `key`. The previous
* implementation in `markdown-text.tsx` used the source string itself as
* the key, which produced React warnings when two segments rendered the
* same `[citation:N]` text.
*/
export function renderCitationToken(token: CitationToken, ordinalKey: number): ReactNode {
if (token.kind === "url") {
return <UrlCitation key={`citation-url-${ordinalKey}`} url={token.url} />;
}
return (
<InlineCitation
key={`citation-${token.isDocsChunk ? "doc-" : ""}${token.chunkId}-${ordinalKey}`}
chunkId={token.chunkId}
isDocsChunk={token.isDocsChunk}
/>
);
}
/**
* Walk a `ReactNode` (string, array, or arbitrary node) and replace any
* `[citation:...]` tokens inside string children with citation badges.
*
* Designed for use inside `Streamdown`/`react-markdown` `components`
* overrides where the renderer hands you `children`. Non-string children
* are returned untouched so block/phrasing structure is preserved.
*/
export function processChildrenWithCitations(
children: ReactNode,
urlMap: CitationUrlMap
): ReactNode {
if (typeof children === "string") {
const segments = parseTextWithCitations(children, urlMap);
if (segments.length === 1 && typeof segments[0] === "string") {
return children;
}
let ordinal = 0;
return segments.map((segment) =>
typeof segment === "string" ? segment : renderCitationToken(segment, ordinal++)
);
}
if (Array.isArray(children)) {
let ordinal = 0;
return children.map((child, childIndex) => {
if (typeof child === "string") {
const segments = parseTextWithCitations(child, urlMap);
if (segments.length === 1 && typeof segments[0] === "string") {
return child;
}
return (
<span key={`citation-seg-${childIndex}`}>
{segments.map((segment) =>
typeof segment === "string" ? segment : renderCitationToken(segment, ordinal++)
)}
</span>
);
}
return child;
});
}
return children;
}

View file

@ -32,7 +32,7 @@ export function DocumentViewer({ title, content, trigger }: DocumentViewerProps)
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<div className="mt-4">
<MarkdownViewer content={content} />
<MarkdownViewer content={content} enableCitations />
</div>
</DialogContent>
</Dialog>

View file

@ -652,7 +652,7 @@ export function EditorPanelContent({
// Plate is heavy on multi-MB docs.
<div className="h-full overflow-y-auto px-5 py-4">
{largeDocAlert}
<MarkdownViewer content={editorDoc.source_markdown} />
<MarkdownViewer content={editorDoc.source_markdown} enableCitations />
</div>
) : renderInPlateEditor ? (
// Editable doc (FILE/NOTE) — Plate editing UX.
@ -670,12 +670,17 @@ export function EditorPanelContent({
reserveToolbarSpace
defaultEditing={isEditing}
className="**:[[role=toolbar]]:bg-sidebar!"
// Render `[citation:N]` badges in view mode only.
// Edit mode keeps raw text so the user can edit/delete
// tokens directly. `local_file` never reaches this branch
// (handled by the source_code editor above).
enableCitations={!isEditing && !isLocalFileMode}
/>
</div>
</div>
) : (
<div className="h-full overflow-y-auto px-5 py-4">
<MarkdownViewer content={editorDoc.source_markdown} />
<MarkdownViewer content={editorDoc.source_markdown} enableCitations />
</div>
)}
</div>

View file

@ -8,9 +8,11 @@ import { useEffect, useMemo, useRef } from "react";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { EditorSaveContext } from "@/components/editor/editor-save-context";
import { CitationKit, injectCitationNodes } from "@/components/editor/plugins/citation-kit";
import { type EditorPreset, presetMap } from "@/components/editor/presets";
import { escapeMdxExpressions } from "@/components/editor/utils/escape-mdx";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
/** Live editor instance returned by `usePlateEditor`. */
export type PlateEditorInstance = ReturnType<typeof usePlateEditor>;
@ -65,6 +67,14 @@ export interface PlateEditorProps {
* without modifying the core editor component.
*/
extraPlugins?: AnyPluginConfig[];
/**
* Render `[citation:N]` and `[citation:URL]` tokens in the deserialized
* markdown as interactive citation badges/popovers (mirrors chat). Only
* meant for read-only views when true, `onMarkdownChange` is suppressed
* because the in-memory tree contains custom inline-void elements that
* have no markdown serialize rule.
*/
enableCitations?: boolean;
}
function PlateEditorContent({
@ -103,6 +113,7 @@ export function PlateEditor({
defaultEditing = false,
preset = "full",
extraPlugins = [],
enableCitations = false,
}: PlateEditorProps) {
const lastMarkdownRef = useRef(markdown);
const lastHtmlRef = useRef(html);
@ -145,6 +156,8 @@ export function PlateEditor({
...(onSave ? [SaveShortcutPlugin] : []),
// Consumer-provided extra plugins
...extraPlugins,
// Citation void inline element (read-only document viewer).
...(enableCitations ? CitationKit : []),
MarkdownPlugin.configure({
options: {
remarkPlugins: [remarkGfm, remarkMath, remarkMdx],
@ -154,8 +167,18 @@ export function PlateEditor({
value: html
? (editor) => editor.api.html.deserialize({ element: html }) as Value
: markdown
? (editor) =>
editor.getApi(MarkdownPlugin).markdown.deserialize(escapeMdxExpressions(markdown))
? (editor) => {
if (!enableCitations) {
return editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
}
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const value = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten));
return injectCitationNodes(value as Descendant[], urlMap) as Value;
}
: undefined,
});
@ -174,13 +197,22 @@ export function PlateEditor({
useEffect(() => {
if (!html && markdown !== undefined && markdown !== lastMarkdownRef.current) {
lastMarkdownRef.current = markdown;
const newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
let newValue: Descendant[];
if (enableCitations) {
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const deserialized = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[];
newValue = injectCitationNodes(deserialized, urlMap);
} else {
newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[];
}
editor.tf.reset();
editor.tf.setValue(newValue);
editor.tf.setValue(newValue as Value);
}
}, [html, markdown, editor]);
}, [html, markdown, editor, enableCitations]);
// When not forced read-only, the user can toggle between editing/viewing.
const canToggleMode = !readOnly && allowModeToggle;
@ -205,6 +237,16 @@ export function PlateEditor({
// (initialized to true via usePlateEditor, toggled via ModeToolbarButton).
{...(readOnly ? { readOnly: true } : {})}
onChange={({ value }) => {
// View-only citation mode: skip serialization. The custom
// `citation` inline-void element has no markdown serialize
// rule, so emitting changes here would overwrite
// `lastMarkdownRef.current` (and downstream copy-to-clipboard
// state in EditorPanelContent) with a tree that loses every
// citation token. `enableCitations` is only ever set in
// read-only paths, so user input cannot reach this branch
// in practice — the guard exists for the initial Plate
// normalize emit.
if (enableCitations) return;
if (onHtmlChange && html) {
const serialized = slateToHtml(value as Descendant[]);
onHtmlChange(serialized);

View file

@ -0,0 +1,218 @@
"use client";
import { type Descendant, KEYS } from "platejs";
import { createPlatePlugin, type PlateElementProps } from "platejs/react";
import type { FC } from "react";
import { InlineCitation, UrlCitation } from "@/components/assistant-ui/inline-citation";
import {
CITATION_REGEX,
type CitationUrlMap,
parseTextWithCitations,
} from "@/lib/citations/citation-parser";
/**
* Plate inline-void node modeling a single `[citation:...]` reference.
*
* Modeled after the existing `MentionPlugin` pattern in
* `inline-mention-editor.tsx` the only confirmed pattern in this repo
* for non-text inline UI. Inline-void elements satisfy Slate's invariant
* that the editor renders both atomic widgets and surrounding text
* cleanly without breaking selection / caret semantics.
*/
export type CitationElementNode = {
type: "citation";
kind: "chunk" | "doc" | "url";
chunkId?: number;
url?: string;
/** Original `[citation:...]` substring for traceability/debugging. */
rawText: string;
children: [{ text: "" }];
};
const CITATION_TYPE = "citation";
const CitationElement: FC<PlateElementProps<CitationElementNode>> = ({
attributes,
children,
element,
}) => {
const isUrl = element.kind === "url";
return (
<span {...attributes} className="inline-flex align-baseline">
<span contentEditable={false}>
{isUrl && element.url ? (
<UrlCitation url={element.url} />
) : element.chunkId !== undefined ? (
<InlineCitation chunkId={element.chunkId} isDocsChunk={element.kind === "doc"} />
) : null}
</span>
{children}
</span>
);
};
const CitationPlugin = createPlatePlugin({
key: CITATION_TYPE,
node: {
isElement: true,
isInline: true,
isVoid: true,
type: CITATION_TYPE,
component: CitationElement,
},
});
/** Plugin kit shape used elsewhere in the editor. */
export const CitationKit = [CitationPlugin];
// ---------------------------------------------------------------------------
// Slate value transform — runs after MarkdownPlugin.deserialize
// ---------------------------------------------------------------------------
// Structural shapes used by the value transform. We cannot use Plate's
// generic Element / Text type predicates directly because `Descendant` is a
// constrained union and our predicates would over-narrow. Casting through
// these row types keeps the walker readable without fighting the types.
type SlateText = { text: string } & Record<string, unknown>;
type SlateElement = { type?: string; children: Descendant[] } & Record<string, unknown>;
function isText(node: Descendant): boolean {
return typeof (node as { text?: unknown }).text === "string";
}
function asText(node: Descendant): SlateText {
return node as unknown as SlateText;
}
function asElement(node: Descendant): SlateElement {
return node as unknown as SlateElement;
}
/**
* Element types whose subtrees we MUST NOT inject citation void elements
* into. Each rationale documented in the citation plan:
* - `KEYS.codeBlock` / `code_line` Plate's schema rejects inline elements
* inside code containers; the user expects literal text inside code.
* - `KEYS.link` `<button>` inside `<a>` is invalid HTML and the link
* swallows the citation click. Mirrors the `<a>` skip in
* `MarkdownViewer`.
*/
const SKIP_SUBTREE_TYPES = new Set<string>([KEYS.codeBlock, "code_line", KEYS.link]);
/**
* Build the marks portion of a Slate text node so we can preserve formatting
* (bold/italic/etc.) on the surrounding text fragments after we split.
*/
function copyMarks(textNode: SlateText): Record<string, unknown> {
const { text: _text, ...marks } = textNode;
return marks;
}
function makeCitationElement(
rawText: string,
segment: { kind: "url"; url: string } | { kind: "chunk"; chunkId: number; isDocsChunk: boolean }
): CitationElementNode {
if (segment.kind === "url") {
return {
type: CITATION_TYPE,
kind: "url",
url: segment.url,
rawText,
children: [{ text: "" }],
};
}
return {
type: CITATION_TYPE,
kind: segment.isDocsChunk ? "doc" : "chunk",
chunkId: segment.chunkId,
rawText,
children: [{ text: "" }],
};
}
/**
* Re-extract the raw `[citation:...]` substrings that produced each parsed
* segment, in source order. Lets us preserve the original literal for
* `rawText` on the inline-void element.
*/
function extractRawCitationMatches(text: string): string[] {
const matches: string[] = [];
CITATION_REGEX.lastIndex = 0;
let m: RegExpExecArray | null = CITATION_REGEX.exec(text);
while (m !== null) {
matches.push(m[0]);
m = CITATION_REGEX.exec(text);
}
return matches;
}
function transformTextNode(node: SlateText, urlMap: CitationUrlMap): Descendant[] {
const segments = parseTextWithCitations(node.text, urlMap);
if (segments.length === 1 && typeof segments[0] === "string") {
return [node as unknown as Descendant];
}
const marks = copyMarks(node);
const rawMatches = extractRawCitationMatches(node.text);
const out: Descendant[] = [];
let citationIdx = 0;
let pendingText: string | null = null;
const flushText = () => {
// Slate inline-void adjacency: emit an empty text node (with copied
// marks) when the citation appears at the very start/end of the text
// node so neighbours of the void always have a text sibling.
out.push({ ...marks, text: pendingText ?? "" } as unknown as Descendant);
pendingText = null;
};
for (const segment of segments) {
if (typeof segment === "string") {
pendingText = (pendingText ?? "") + segment;
} else {
flushText();
const raw = rawMatches[citationIdx] ?? "";
out.push(makeCitationElement(raw, segment) as unknown as Descendant);
citationIdx += 1;
// Always reset pendingText so the next loop iteration emits a
// trailing empty text node if no further plain text follows.
pendingText = "";
}
}
flushText();
return out;
}
function transformChildren(children: Descendant[], urlMap: CitationUrlMap): Descendant[] {
const out: Descendant[] = [];
for (const child of children) {
if (isText(child)) {
out.push(...transformTextNode(asText(child), urlMap));
continue;
}
const elementChild = asElement(child);
const elementType = (elementChild.type ?? "") as string;
if (elementType && SKIP_SUBTREE_TYPES.has(elementType)) {
out.push(child);
continue;
}
out.push({
...elementChild,
children: transformChildren(elementChild.children, urlMap),
} as unknown as Descendant);
}
return out;
}
/**
* Walk a deserialized Slate value and replace every `[citation:...]`
* substring with a `citation` inline-void element. URL placeholders
* created by `preprocessCitationMarkdown` are resolved through `urlMap`.
*
* Subtrees of `code_block`, `code_line`, and `link` are returned as-is
* see `SKIP_SUBTREE_TYPES` above.
*/
export function injectCitationNodes(value: Descendant[], urlMap: CitationUrlMap): Descendant[] {
return transformChildren(value, urlMap);
}

View file

@ -7,7 +7,7 @@
// break the MDX parser. This module sanitises them before deserialization.
// ---------------------------------------------------------------------------
const FENCED_OR_INLINE_CODE = /(```[\s\S]*?```|`[^`\n]+`)/g;
import { FENCED_OR_INLINE_CODE } from "@/lib/markdown/code-regions";
// Strip HTML comments that MDX cannot parse.
// PDF converters emit <!-- PageHeader="..." -->, <!-- PageBreak -->, etc.

View file

@ -104,7 +104,13 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
return;
}
throw new Error(`Stream error: ${response.status}`);
const body = await response.text().catch(() => "");
const errorCode = response.status === 409 ? "THREAD_BUSY" : "SERVER_ERROR";
const message =
errorCode === "THREAD_BUSY"
? "A previous response is still stopping. Please try again in a moment."
: `Stream error: ${response.status}`;
throw Object.assign(new Error(body || message), { errorCode });
}
for await (const event of readSSEStream(response)) {
@ -115,10 +121,12 @@ export function AnonymousChat({ model }: AnonymousChatProps) {
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content + event.delta } : m))
);
} else if (event.type === "error") {
const message =
event.errorCode === "THREAD_BUSY"
? "A previous response is still stopping. Please try again in a moment."
: event.errorText;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: m.content || event.errorText } : m
)
prev.map((m) => (m.id === assistantId ? { ...m, content: m.content || message } : m))
);
} else if ("type" in event && event.type === "data-token-usage") {
// After streaming completes, refresh quota

View file

@ -9,6 +9,7 @@ import {
import { Turnstile, type TurnstileInstance } from "@marsidev/react-turnstile";
import { ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import {
createTokenUsageStore,
@ -17,10 +18,14 @@ import {
} from "@/components/assistant-ui/token-usage-context";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import {
addStepSeparator,
addToolCall,
appendReasoning,
appendText,
appendToolInputDelta,
buildContentForUI,
type ContentPartsState,
endReasoning,
FrameBatchedUpdater,
readSSEStream,
type ThinkingStepData,
@ -32,7 +37,9 @@ import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
const TOOLS_WITH_UI = new Set(["web_search", "document_qna"]);
// Render all tool calls via ToolFallback; backend keeps persisted
// payloads bounded by summarising / truncating outputs.
const TOOLS_WITH_UI = "all" as const;
const TURNSTILE_SITE_KEY = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY ?? "";
/** Try to parse a CAPTCHA_REQUIRED or CAPTCHA_INVALID code from a non-ok response. */
@ -48,6 +55,48 @@ function parseCaptchaError(status: number, body: string): string | null {
return null;
}
function normalizeFreeChatErrorMessage(error: unknown): string {
if (!(error instanceof Error)) return "An unexpected error occurred";
const code = (error as Error & { errorCode?: string }).errorCode;
if (code === "THREAD_BUSY") {
return "A previous response is still stopping. Please try again in a moment.";
}
return error.message || "An unexpected error occurred";
}
function toFreeChatHttpError(status: number, body: string): Error & { errorCode?: string } {
let errorCode: string | undefined;
let message = body || `Server error: ${status}`;
try {
const parsed = JSON.parse(body) as Record<string, unknown>;
const detail =
typeof parsed.detail === "object" && parsed.detail !== null
? (parsed.detail as Record<string, unknown>)
: null;
errorCode =
(typeof detail?.error_code === "string" ? detail.error_code : undefined) ??
(typeof detail?.errorCode === "string" ? detail.errorCode : undefined) ??
(typeof parsed.error_code === "string" ? parsed.error_code : undefined) ??
(typeof parsed.errorCode === "string" ? parsed.errorCode : undefined);
message =
(typeof detail?.message === "string" ? detail.message : undefined) ??
(typeof parsed.message === "string" ? parsed.message : undefined) ??
(typeof parsed.detail === "string" ? parsed.detail : undefined) ??
message;
} catch {
// non-json response
}
if (!errorCode) {
if (status === 409) errorCode = "THREAD_BUSY";
else if (status === 429) errorCode = "RATE_LIMITED";
else if (status === 401 || status === 403) errorCode = "AUTH_EXPIRED";
else errorCode = "SERVER_ERROR";
}
return Object.assign(new Error(message), { errorCode });
}
export function FreeChatPage() {
const anonMode = useAnonymousMode();
const modelSlug = anonMode.isAnonymous ? anonMode.modelSlug : "";
@ -117,7 +166,7 @@ export function FreeChatPage() {
const body = await response.text().catch(() => "");
const captchaCode = parseCaptchaError(response.status, body);
if (captchaCode) return "captcha";
throw new Error(body || `Server error: ${response.status}`);
throw toFreeChatHttpError(response.status, body);
}
const currentThinkingSteps = new Map<string, ThinkingStepData>();
@ -125,6 +174,7 @@ export function FreeChatPage() {
const contentPartsState: ContentPartsState = {
contentParts: [],
currentTextPartIndex: -1,
currentReasoningPartIndex: -1,
toolCallIndices: new Map(),
};
const { toolCallIndices } = contentPartsState;
@ -139,6 +189,10 @@ export function FreeChatPage() {
);
};
const scheduleFlush = () => batcher.schedule(flushMessages);
const forceFlush = () => {
scheduleFlush();
batcher.flush();
};
try {
for await (const parsed of readSSEStream(response)) {
@ -148,29 +202,74 @@ export function FreeChatPage() {
scheduleFlush();
break;
case "tool-input-start":
addToolCall(contentPartsState, TOOLS_WITH_UI, parsed.toolCallId, parsed.toolName, {});
batcher.flush();
case "reasoning-delta":
appendReasoning(contentPartsState, parsed.delta);
scheduleFlush();
break;
case "tool-input-available":
case "reasoning-end":
endReasoning(contentPartsState);
scheduleFlush();
break;
case "start-step":
addStepSeparator(contentPartsState);
scheduleFlush();
break;
case "finish-step":
break;
case "tool-input-start":
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
parsed.toolCallId,
parsed.toolName,
{},
false,
parsed.langchainToolCallId
);
forceFlush();
break;
case "tool-input-delta":
appendToolInputDelta(contentPartsState, parsed.toolCallId, parsed.inputTextDelta);
scheduleFlush();
break;
case "tool-input-available": {
const finalArgsText = JSON.stringify(parsed.input ?? {}, null, 2);
if (toolCallIndices.has(parsed.toolCallId)) {
updateToolCall(contentPartsState, parsed.toolCallId, { args: parsed.input || {} });
updateToolCall(contentPartsState, parsed.toolCallId, {
args: parsed.input || {},
argsText: finalArgsText,
langchainToolCallId: parsed.langchainToolCallId,
});
} else {
addToolCall(
contentPartsState,
TOOLS_WITH_UI,
parsed.toolCallId,
parsed.toolName,
parsed.input || {}
parsed.input || {},
false,
parsed.langchainToolCallId
);
updateToolCall(contentPartsState, parsed.toolCallId, {
argsText: finalArgsText,
});
}
batcher.flush();
forceFlush();
break;
}
case "tool-output-available":
updateToolCall(contentPartsState, parsed.toolCallId, { result: parsed.output });
batcher.flush();
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
});
forceFlush();
break;
case "data-thinking-step": {
@ -187,7 +286,9 @@ export function FreeChatPage() {
break;
case "error":
throw new Error(parsed.errorText || "Server error");
throw Object.assign(new Error(parsed.errorText || "Server error"), {
errorCode: parsed.errorCode,
});
}
}
batcher.flush();
@ -277,7 +378,7 @@ export function FreeChatPage() {
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return;
console.error("[FreeChatPage] Chat error:", error);
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
const errorText = normalizeFreeChatErrorMessage(error);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
@ -336,7 +437,7 @@ export function FreeChatPage() {
} catch (error) {
if (error instanceof Error && error.name === "AbortError") return;
console.error("[FreeChatPage] Retry error:", error);
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
const errorText = normalizeFreeChatErrorMessage(error);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMsgId
@ -369,6 +470,7 @@ export function FreeChatPage() {
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<StepSeparatorDataUI />
<div className="flex h-full flex-col overflow-hidden">
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border/40 px-4">
<FreeModelSelector />

View file

@ -1,11 +1,10 @@
"use client";
import { AuiIf, ThreadPrimitive } from "@assistant-ui/react";
import { ArrowDownIcon } from "lucide-react";
import type { FC } from "react";
import { AssistantMessage } from "@/components/assistant-ui/assistant-message";
import { ChatViewport } from "@/components/assistant-ui/chat-viewport";
import { EditComposer } from "@/components/assistant-ui/edit-composer";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { UserMessage } from "@/components/assistant-ui/user-message";
import { FreeComposer } from "./free-composer";
@ -24,20 +23,6 @@ const FreeThreadWelcome: FC = () => {
);
};
const ThreadScrollToBottom: FC = () => {
return (
<ThreadPrimitive.ScrollToBottom asChild>
<TooltipIconButton
tooltip="Scroll to bottom"
variant="outline"
className="aui-thread-scroll-to-bottom -top-12 absolute z-10 self-center rounded-full p-4 disabled:invisible dark:bg-main-panel dark:hover:bg-accent"
>
<ArrowDownIcon />
</TooltipIconButton>
</ThreadPrimitive.ScrollToBottom>
);
};
export const FreeThread: FC = () => {
return (
<ThreadPrimitive.Root
@ -46,10 +31,12 @@ export const FreeThread: FC = () => {
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport
turnAnchor="top"
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4"
style={{ scrollbarGutter: "stable" }}
<ChatViewport
footer={
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<FreeComposer />
</AuiIf>
}
>
<AuiIf condition={({ thread }) => thread.isEmpty}>
<FreeThreadWelcome />
@ -62,21 +49,7 @@ export const FreeThread: FC = () => {
AssistantMessage,
}}
/>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="grow" />
</AuiIf>
<ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<FreeComposer />
</AuiIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ChatViewport>
</ThreadPrimitive.Root>
);
};

View file

@ -26,9 +26,9 @@ import {
type Tab,
} from "@/atoms/tabs/tabs.atom";
import { currentUserAtom } from "@/atoms/user/user-query.atoms";
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
import { SearchSpaceSettingsDialog } from "@/components/settings/search-space-settings-dialog";
import { TeamDialog } from "@/components/settings/team-dialog";
import { ActionLogSheet } from "@/components/agent-action-log/action-log-sheet";
import { UserSettingsDialog } from "@/components/settings/user-settings-dialog";
import {
AlertDialog,
@ -681,14 +681,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
}
}, [chatToRename, newChatTitle, queryClient, searchSpaceId, tSidebar]);
// Page usage
const pageUsage = user
? {
pagesUsed: user.pages_used,
pagesLimit: user.pages_limit,
}
: undefined;
// Detect if we're on the chat page (needs overflow-hidden for chat's own scroll)
const isChatPage = pathname?.includes("/new-chat") ?? false;
@ -723,7 +715,6 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
onManageMembers={handleManageMembers}
onUserSettings={handleUserSettings}
onLogout={handleLogout}
pageUsage={pageUsage}
theme={theme}
setTheme={setTheme}
isChatPage={isChatPage}

View file

@ -120,45 +120,22 @@ interface LayoutShellProps {
function MainContentPanel({
isChatPage,
isSidebarCollapsed,
onTabSwitch,
onNewChat,
leftActions,
showResizeHandle = false,
onResizeMouseDown,
children,
}: {
isChatPage: boolean;
isSidebarCollapsed: boolean;
onTabSwitch?: (tab: Tab) => void;
onNewChat?: () => void;
leftActions?: React.ReactNode;
showResizeHandle?: boolean;
onResizeMouseDown?: (e: React.MouseEvent) => void;
children: React.ReactNode;
}) {
const activeTab = useAtomValue(activeTabAtom);
const isDocumentTab = activeTab?.type === "document";
return (
<div
className={cn(
"relative flex flex-1 flex-col min-w-0 -ml-2",
isSidebarCollapsed ? "" : "border-l border-border/60"
)}
>
{showResizeHandle && onResizeMouseDown && (
<div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown}
className="absolute left-0 top-0 hidden md:block h-full w-2 -translate-x-1/2 cursor-col-resize z-30 focus:outline-none"
/>
)}
<div className="relative isolate flex flex-1 flex-col min-w-0">
<TabBar
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
@ -538,14 +515,26 @@ export function LayoutShell({
</SidebarSlideOutPanel>
</div>
{/* Resize handle — negative margins eat the flex gap so spacing stays unchanged */}
{!isCollapsed && (
<div
role="slider"
aria-label="Resize sidebar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={50}
tabIndex={0}
onMouseDown={onResizeMouseDown}
className="hidden md:block h-full cursor-col-resize z-30 focus:outline-none"
style={{ width: 8, marginLeft: -8, marginRight: -8 }}
/>
)}
{/* Main content panel */}
<MainContentPanel
isChatPage={isChatPage}
isSidebarCollapsed={isCollapsed}
onTabSwitch={onTabSwitch}
onNewChat={onNewChat}
showResizeHandle={!isCollapsed}
onResizeMouseDown={onResizeMouseDown}
leftActions={
isCollapsed ? (
<SidebarCollapseButton isCollapsed={isCollapsed} onToggle={toggleCollapsed} />

View file

@ -0,0 +1,15 @@
"use client";
import { useQuery } from "@rocicorp/zero/react";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { queries } from "@/zero/queries";
import { PageUsageDisplay } from "./PageUsageDisplay";
export function AuthenticatedPageUsageDisplay() {
const isAnonymous = useIsAnonymous();
const [me] = useQuery(queries.user.me({}));
if (isAnonymous || !me) return null;
return <PageUsageDisplay pagesUsed={me.pagesUsed} pagesLimit={me.pagesLimit} />;
}

View file

@ -23,9 +23,7 @@ import { useTranslations } from "next-intl";
import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
@ -74,12 +72,12 @@ import type { DocumentTypeEnum } from "@/contracts/types/document.types";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI, usePlatform } from "@/hooks/use-platform";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { anonymousChatApiService } from "@/lib/apis/anonymous-chat-api.service";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { foldersApiService } from "@/lib/apis/folders-api.service";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { uploadFolderScan } from "@/lib/folder-sync-upload";
import { getSupportedExtensionsSet } from "@/lib/supported-extensions";
import { queries } from "@/zero/queries/index";

View file

@ -1,23 +1,18 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useQuery } from "@rocicorp/zero/react";
import { Progress } from "@/components/ui/progress";
import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { queries } from "@/zero/queries";
export function PremiumTokenUsageDisplay() {
const isAnonymous = useIsAnonymous();
const { data: tokenStatus } = useQuery({
queryKey: ["token-status"],
queryFn: () => stripeApiService.getTokenStatus(),
staleTime: 60_000,
enabled: !isAnonymous,
});
const [me] = useQuery(queries.user.me({}));
if (!tokenStatus) return null;
if (isAnonymous || !me) return null;
const usagePercentage = Math.min(
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
(me.premiumTokensUsed / Math.max(me.premiumTokensLimit, 1)) * 100,
100
);
@ -31,8 +26,7 @@ export function PremiumTokenUsageDisplay() {
<div className="space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{formatTokens(tokenStatus.premium_tokens_used)} /{" "}
{formatTokens(tokenStatus.premium_tokens_limit)} tokens
{formatTokens(me.premiumTokensUsed)} / {formatTokens(me.premiumTokensLimit)} tokens
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>

View file

@ -12,9 +12,9 @@ import { useIsAnonymous } from "@/contexts/anonymous-mode";
import { cn } from "@/lib/utils";
import { SIDEBAR_MIN_WIDTH } from "../../hooks/useSidebarResize";
import type { ChatItem, NavItem, PageUsage, SearchSpace, User } from "../../types/layout.types";
import { AuthenticatedPageUsageDisplay } from "./AuthenticatedPageUsageDisplay";
import { ChatListItem } from "./ChatListItem";
import { NavSection } from "./NavSection";
import { PageUsageDisplay } from "./PageUsageDisplay";
import { PremiumTokenUsageDisplay } from "./PremiumTokenUsageDisplay";
import { SidebarButton } from "./SidebarButton";
import { SidebarCollapseButton } from "./SidebarCollapseButton";
@ -342,9 +342,7 @@ function SidebarUsageFooter({
return (
<div className="px-3 py-3 border-t border-border/60 space-y-3">
<PremiumTokenUsageDisplay />
{pageUsage && (
<PageUsageDisplay pagesUsed={pageUsage.pagesUsed} pagesLimit={pageUsage.pagesLimit} />
)}
<AuthenticatedPageUsageDisplay />
<div className="space-y-0.5">
<Link
href={`/dashboard/${searchSpaceId}/more-pages`}

View file

@ -316,10 +316,10 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
</Button>
</AlertDescription>
</Alert>
<MarkdownViewer content={doc.source_markdown} />
<MarkdownViewer content={doc.source_markdown} enableCitations />
</>
) : (
<MarkdownViewer content={doc.source_markdown} />
<MarkdownViewer content={doc.source_markdown} enableCitations />
)}
</div>
</div>

View file

@ -3,6 +3,9 @@ import { createMathPlugin } from "@streamdown/math";
import { Streamdown, type StreamdownProps } from "streamdown";
import "katex/dist/katex.min.css";
import Image from "next/image";
import { useMemo } from "react";
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
import { cn } from "@/lib/utils";
const code = createCodePlugin({
@ -21,8 +24,21 @@ interface MarkdownViewerProps {
content: string;
className?: string;
maxLength?: number;
/**
* When true, render `[citation:N]` / `[citation:URL]` tokens as the
* interactive citation badges/popovers used in chat. Default `false`
* so callers that don't need citations are unchanged.
*
* Note: we deliberately do NOT override `<a>` to inject citations into
* link text that would produce `<button>` inside `<a>` (invalid
* HTML). A `[citation:N]` token literally placed inside markdown link
* text stays as raw text.
*/
enableCitations?: boolean;
}
const EMPTY_URL_MAP: CitationUrlMap = new Map();
/**
* If the entire content is wrapped in a single ```markdown or ```md
* code fence, strip the fence so the inner markdown renders properly.
@ -85,14 +101,45 @@ function convertLatexDelimiters(content: string): string {
return content;
}
export function MarkdownViewer({ content, className, maxLength }: MarkdownViewerProps) {
export function MarkdownViewer({
content,
className,
maxLength,
enableCitations = false,
}: MarkdownViewerProps) {
const isTruncated = maxLength != null && content.length > maxLength;
const displayContent = isTruncated ? content.slice(0, maxLength) : content;
const processedContent = convertLatexDelimiters(stripOuterMarkdownFence(displayContent));
// Preprocess for URL placeholders BEFORE LaTeX so GFM autolinks don't
// split `[citation:https://…]` apart. The preprocess is code-fence
// aware so citations inside fenced code stay literal.
const { processedContent, urlMap } = useMemo(() => {
const stripped = stripOuterMarkdownFence(displayContent);
if (!enableCitations) {
return {
processedContent: convertLatexDelimiters(stripped),
urlMap: EMPTY_URL_MAP,
};
}
const { content: rewritten, urlMap: map } = preprocessCitationMarkdown(stripped);
return {
processedContent: convertLatexDelimiters(rewritten),
urlMap: map,
};
}, [displayContent, enableCitations]);
// Phrasing/block renderers wrap their string children through the
// citation renderer when `enableCitations` is on. We deliberately do
// NOT override `<a>` (would produce <button> inside <a>) and we do
// NOT touch the inline/fenced `code` paths (citations stay literal
// inside code, matching markdown-text.tsx behavior).
const wrap = (children: React.ReactNode): React.ReactNode =>
enableCitations ? processChildrenWithCitations(children, urlMap) : children;
const components: StreamdownProps["components"] = {
p: ({ children, ...props }) => (
<p className="my-2" {...props}>
{children}
{wrap(children)}
</p>
),
a: ({ children, ...props }) => (
@ -105,31 +152,49 @@ export function MarkdownViewer({ content, className, maxLength }: MarkdownViewer
{children}
</a>
),
li: ({ children, ...props }) => <li {...props}>{children}</li>,
li: ({ children, ...props }) => <li {...props}>{wrap(children)}</li>,
ul: ({ ...props }) => <ul className="list-disc pl-5 my-2" {...props} />,
ol: ({ ...props }) => <ol className="list-decimal pl-5 my-2" {...props} />,
h1: ({ children, ...props }) => (
<h1 className="text-2xl font-bold mt-6 mb-2" {...props}>
{children}
{wrap(children)}
</h1>
),
h2: ({ children, ...props }) => (
<h2 className="text-xl font-bold mt-5 mb-2" {...props}>
{children}
{wrap(children)}
</h2>
),
h3: ({ children, ...props }) => (
<h3 className="text-lg font-bold mt-4 mb-2" {...props}>
{children}
{wrap(children)}
</h3>
),
h4: ({ children, ...props }) => (
<h4 className="text-base font-bold mt-3 mb-1" {...props}>
{children}
{wrap(children)}
</h4>
),
blockquote: ({ ...props }) => (
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props} />
h5: ({ children, ...props }) => (
<h5 className="text-sm font-bold mt-3 mb-1" {...props}>
{wrap(children)}
</h5>
),
h6: ({ children, ...props }) => (
<h6 className="text-xs font-bold mt-3 mb-1" {...props}>
{wrap(children)}
</h6>
),
strong: ({ children, ...props }) => (
<strong className="font-semibold" {...props}>
{wrap(children)}
</strong>
),
em: ({ children, ...props }) => <em {...props}>{wrap(children)}</em>,
blockquote: ({ children, ...props }) => (
<blockquote className="border-l-4 border-muted pl-4 italic my-2" {...props}>
{wrap(children)}
</blockquote>
),
hr: ({ ...props }) => <hr className="my-4 border-muted" {...props} />,
img: ({ src, alt, width: _w, height: _h, ...props }) => {
@ -163,17 +228,21 @@ export function MarkdownViewer({ content, className, maxLength }: MarkdownViewer
<table className="w-full divide-y divide-border" {...props} />
</div>
),
th: ({ ...props }) => (
th: ({ children, ...props }) => (
<th
className="px-4 py-2.5 text-left text-sm font-semibold text-muted-foreground/80 bg-muted/30 border-r border-border/40 last:border-r-0"
{...props}
/>
>
{wrap(children)}
</th>
),
td: ({ ...props }) => (
td: ({ children, ...props }) => (
<td
className="px-4 py-2.5 text-sm border-t border-r border-border/40 last:border-r-0"
{...props}
/>
>
{wrap(children)}
</td>
),
};

View file

@ -236,6 +236,93 @@ interface DisplayItem {
isAutoMode: boolean;
}
const TruncatedNameWithTooltip: React.FC<{
text: string;
className?: string;
enableTooltip: boolean;
}> = ({ text, className, enableTooltip }) => {
const textRef = useRef<HTMLSpanElement>(null);
const openTimerRef = useRef<number | undefined>(undefined);
const [isTruncated, setIsTruncated] = useState(false);
const [open, setOpen] = useState(false);
const recalcTruncation = useCallback(() => {
const el = textRef.current;
if (!el) return;
setIsTruncated(el.scrollWidth > el.clientWidth + 1);
}, []);
useEffect(() => {
if (!enableTooltip) return;
const el = textRef.current;
if (!el) return;
const raf = requestAnimationFrame(recalcTruncation);
recalcTruncation();
const observer = new ResizeObserver(recalcTruncation);
observer.observe(el);
if (el.parentElement) observer.observe(el.parentElement);
window.addEventListener("resize", recalcTruncation);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
window.removeEventListener("resize", recalcTruncation);
};
}, [enableTooltip, recalcTruncation]);
useEffect(() => {
// Recompute when row text changes.
void text;
requestAnimationFrame(recalcTruncation);
}, [text, recalcTruncation]);
useEffect(
() => () => {
if (openTimerRef.current) window.clearTimeout(openTimerRef.current);
},
[]
);
if (!enableTooltip) {
return (
<span ref={textRef} className={cn("block max-w-full", className)}>
{text}
</span>
);
}
const handleOpenChange = (nextOpen: boolean) => {
if (openTimerRef.current) {
window.clearTimeout(openTimerRef.current);
openTimerRef.current = undefined;
}
if (!nextOpen) {
setOpen(false);
return;
}
if (!isTruncated) return;
openTimerRef.current = window.setTimeout(() => {
setOpen(true);
openTimerRef.current = undefined;
}, 220);
};
return (
<Tooltip open={open} onOpenChange={handleOpenChange}>
<TooltipTrigger asChild>
<span ref={textRef} className={cn("block max-w-full", className)}>
{text}
</span>
</TooltipTrigger>
<TooltipContent side="top" align="start">
{text}
</TooltipContent>
</Tooltip>
);
};
// ─── Component ──────────────────────────────────────────────────────
interface ModelSelectorProps {
@ -936,7 +1023,11 @@ export function ModelSelector({
{/* Model info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{config.name}</span>
<TruncatedNameWithTooltip
text={config.name}
enableTooltip={!isMobile}
className="font-medium text-sm truncate"
/>
{isAutoMode && (
<Badge
variant="secondary"

View file

@ -17,7 +17,6 @@ const demoPlans = [
"Self Hostable",
"500 pages included to start",
"3 million premium tokens to start",
"Earn up to 3,000+ bonus pages for free",
"Includes access to OpenAI text, audio and image models",
"Realtime Collaborative Group Chats with teammates",
"Community support on Discord",

View file

@ -1,6 +1,7 @@
"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { StepSeparatorDataUI } from "@/components/assistant-ui/step-separator";
import { ThinkingStepsDataUI } from "@/components/assistant-ui/thinking-steps";
import { Navbar } from "@/components/homepage/navbar";
import { ReportPanel } from "@/components/report-panel/report-panel";
@ -41,6 +42,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<Navbar scrolledBgClassName={navbarScrolledBg} />
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<StepSeparatorDataUI />
<div className="flex h-screen pt-16 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<PublicThread footer={<PublicChatFooter shareToken={shareToken} />} />

View file

@ -13,6 +13,7 @@ import Image from "next/image";
import { type FC, type ReactNode, useState } from "react";
import { CitationMetadataProvider } from "@/components/assistant-ui/citation-metadata-context";
import { MarkdownText } from "@/components/assistant-ui/markdown-text";
import { ReasoningMessagePart } from "@/components/assistant-ui/reasoning-message-part";
import { ToolFallback } from "@/components/assistant-ui/tool-fallback";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { GenerateImageToolUI } from "@/components/tool-ui/generate-image";
@ -44,20 +45,21 @@ export const PublicThread: FC<PublicThreadProps> = ({ footer }) => {
["--thread-max-width" as string]: "44rem",
}}
>
<ThreadPrimitive.Viewport className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4">
<ThreadPrimitive.Viewport
scrollToBottomOnInitialize
scrollToBottomOnThreadSwitch
className="aui-thread-viewport relative flex flex-1 min-h-0 flex-col overflow-y-auto px-4 pt-4 pb-6"
>
<ThreadPrimitive.Messages
components={{
UserMessage: PublicUserMessage,
AssistantMessage: PublicAssistantMessage,
}}
/>
{/* Spacer to ensure footer doesn't overlap last message */}
<div className="h-24" />
</ThreadPrimitive.Viewport>
{footer && (
<div className="sticky bottom-0 z-20 border-t bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60">
<div className="border-t bg-main-panel/95 backdrop-blur supports-backdrop-filter:bg-main-panel/60">
{footer}
</div>
)}
@ -157,6 +159,7 @@ const PublicAssistantMessage: FC = () => {
<MessagePrimitive.Parts
components={{
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: {
generate_podcast: GeneratePodcastToolUI,

View file

@ -516,7 +516,7 @@ export function ReportPanelContent({
) : reportContent.content ? (
isReadOnly ? (
<div className="h-full overflow-y-auto px-5 py-4">
<MarkdownViewer content={reportContent.content} />
<MarkdownViewer content={reportContent.content} enableCitations />
</div>
) : (
<PlateEditor
@ -531,6 +531,9 @@ export function ReportPanelContent({
reserveToolbarSpace
defaultEditing={isEditing}
className="[&_[role=toolbar]]:!bg-sidebar"
// Show citation badges in view mode; raw `[citation:N]`
// text in edit mode so users can edit/delete tokens.
enableCitations={!isEditing}
/>
)
) : (

View file

@ -1,5 +1,6 @@
"use client";
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Minus, Plus } from "lucide-react";
import { useParams } from "next/navigation";
@ -11,6 +12,7 @@ import { Spinner } from "@/components/ui/spinner";
import { stripeApiService } from "@/lib/apis/stripe-api.service";
import { AppError } from "@/lib/error";
import { cn } from "@/lib/utils";
import { queries } from "@/zero/queries";
const TOKEN_PACK_SIZE = 1_000_000;
const PRICE_PER_PACK_USD = 1;
@ -21,11 +23,15 @@ export function BuyTokensContent() {
const searchSpaceId = Number(params?.search_space_id);
const [quantity, setQuantity] = useState(1);
// Server config flag: stays on REST, not per-user.
const { data: tokenStatus } = useQuery({
queryKey: ["token-status"],
queryFn: () => stripeApiService.getTokenStatus(),
});
// Live per-user usage via Zero.
const [me] = useZeroQuery(queries.user.me({}));
const purchaseMutation = useMutation({
mutationFn: stripeApiService.createTokenCheckoutSession,
onSuccess: (response) => {
@ -54,12 +60,11 @@ export function BuyTokensContent() {
);
}
const usagePercentage = tokenStatus
? Math.min(
(tokenStatus.premium_tokens_used / Math.max(tokenStatus.premium_tokens_limit, 1)) * 100,
100
)
: 0;
const used = me?.premiumTokensUsed ?? 0;
const limit = me?.premiumTokensLimit ?? 0;
// Mirrors the backend formula in stripe_routes.py:608 (max(0, limit - used)).
const remaining = Math.max(0, limit - used);
const usagePercentage = me ? Math.min((used / Math.max(limit, 1)) * 100, 100) : 0;
return (
<div className="w-full space-y-5">
@ -68,18 +73,17 @@ export function BuyTokensContent() {
<p className="mt-1 text-sm text-muted-foreground">$1 per 1M tokens, pay as you go</p>
</div>
{tokenStatus && (
{me && (
<div className="rounded-lg border bg-muted/20 p-3 space-y-1.5">
<div className="flex justify-between items-center text-xs">
<span className="text-muted-foreground">
{tokenStatus.premium_tokens_used.toLocaleString()} /{" "}
{tokenStatus.premium_tokens_limit.toLocaleString()} premium tokens
{used.toLocaleString()} / {limit.toLocaleString()} premium tokens
</span>
<span className="font-medium">{usagePercentage.toFixed(0)}%</span>
</div>
<Progress value={usagePercentage} className="h-1.5" />
<p className="text-[11px] text-muted-foreground">
{tokenStatus.premium_tokens_remaining.toLocaleString()} tokens remaining
{remaining.toLocaleString()} tokens remaining
</p>
</div>
)}

View file

@ -11,7 +11,7 @@ import {
RefreshCw,
ScanEye,
} from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
globalImageGenConfigsAtom,
@ -143,23 +143,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
}));
const [savingRole, setSavingRole] = useState<string | null>(null);
const savingRef = useRef(false);
useEffect(() => {
if (!savingRef.current) {
setAssignments({
agent_llm_id: preferences.agent_llm_id ?? "",
document_summary_llm_id: preferences.document_summary_llm_id ?? "",
image_generation_config_id: preferences.image_generation_config_id ?? "",
vision_llm_config_id: preferences.vision_llm_config_id ?? "",
});
}
}, [
preferences?.agent_llm_id,
preferences?.document_summary_llm_id,
preferences?.image_generation_config_id,
preferences?.vision_llm_config_id,
]);
const handleRoleAssignment = useCallback(
async (prefKey: string, configId: string) => {
@ -167,7 +150,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
setSavingRole(prefKey);
savingRef.current = true;
try {
await updatePreferences({
@ -177,7 +159,6 @@ export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
toast.success("Role assignment updated");
} finally {
setSavingRole(null);
savingRef.current = false;
}
},
[updatePreferences, searchSpaceId]

View file

@ -1,21 +1,14 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, Mail } from "lucide-react";
import { Check, ExternalLink } from "lucide-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { toast } from "sonner";
import { USER_QUERY_KEY } from "@/atoms/user/user-query.atoms";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Spinner } from "@/components/ui/spinner";
@ -33,7 +26,6 @@ export function MorePagesContent() {
const params = useParams();
const queryClient = useQueryClient();
const searchSpaceId = params?.search_space_id ?? "";
const [claimOpen, setClaimOpen] = useState(false);
useEffect(() => {
trackIncentivePageViewed();
@ -79,35 +71,10 @@ export function MorePagesContent() {
<div className="text-center">
<h2 className="text-xl font-bold tracking-tight">Get Free Pages</h2>
<p className="mt-1 text-sm text-muted-foreground">
Claim your free page offer and earn bonus pages
Earn bonus pages by completing tasks
</p>
</div>
{/* 3k free offer */}
<Card className="border-emerald-500/30 bg-emerald-500/5">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-emerald-600 text-white text-xs font-bold">
3k
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold">Claim 3,000 Free Pages</p>
<p className="text-xs text-muted-foreground">
Limited offer. Schedule a meeting or email us to claim.
</p>
</div>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
onClick={() => setClaimOpen(true)}
>
Claim
</Button>
</CardContent>
</Card>
<Separator />
{/* Free tasks */}
<div className="space-y-2">
<h3 className="text-sm font-semibold">Earn Bonus Pages</h3>
{isLoading ? (
@ -182,7 +149,6 @@ export function MorePagesContent() {
<Separator />
{/* Link to buy pages */}
<div className="text-center">
<p className="text-sm text-muted-foreground">Need more?</p>
{pageBuyingEnabled ? (
@ -197,25 +163,6 @@ export function MorePagesContent() {
</p>
)}
</div>
{/* Claim 3k dialog */}
<Dialog open={claimOpen} onOpenChange={setClaimOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Claim 3,000 Free Pages</DialogTitle>
<DialogDescription>
Send us an email to claim your free 3,000 pages. Include your account email and
primary usecase for free pages.
</DialogDescription>
</DialogHeader>
<Button asChild className="w-full gap-2">
<a href="mailto:rohan@surfsense.com?subject=Claim%203%2C000%20Free%20Pages&body=Hi%2C%20I'd%20like%20to%20claim%20the%203%2C000%20free%20pages%20offer.%0A%0AMy%20account%20email%3A%20">
<Mail className="h-4 w-4" />
rohan@surfsense.com
</a>
</Button>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -116,7 +116,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
const content: Record<string, React.ReactNode> = {
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
models: <AgentModelManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager key={searchSpaceId} searchSpaceId={searchSpaceId} />,
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,
"team-roles": <RolesManager searchSpaceId={searchSpaceId} />,

View file

@ -8,6 +8,7 @@ import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { getToolDisplayName } from "@/contracts/enums/toolIcons";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
@ -77,7 +78,7 @@ function GenericApprovalCard({
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
const [isEditing, setIsEditing] = useState(false);
const displayName = toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const displayName = getToolDisplayName(toolName);
const mcpServer = interruptData.context?.mcp_server as string | undefined;
const toolDescription = interruptData.context?.tool_description as string | undefined;
@ -186,12 +187,11 @@ function GenericApprovalCard({
</>
)}
{/* Parameters */}
{Object.keys(args).length > 0 && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Parameters</p>
<p className="text-xs font-medium text-muted-foreground">Inputs</p>
{phase === "pending" && isEditing ? (
<ParamEditor
params={editedParams}