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

This commit is contained in:
Anish Sarkar 2026-05-13 03:02:23 +05:30
commit 481bb406b6
757 changed files with 70989 additions and 4089 deletions

View file

@ -9,7 +9,6 @@ import { trackLoginSuccess } from "@/lib/posthog/events";
interface TokenHandlerProps {
redirectPath?: string; // Default path to redirect after storing token (if no saved path)
tokenParamName?: string; // Name of the URL parameter containing the token
storageKey?: string; // Key to use when storing in localStorage (kept for backwards compatibility)
}
/**
@ -19,12 +18,10 @@ interface TokenHandlerProps {
*
* @param redirectPath - Default path to redirect after storing token (default: '/dashboard')
* @param tokenParamName - Name of the URL parameter containing the token (default: 'token')
* @param storageKey - Key to use when storing in localStorage (default: 'surfsense_bearer_token')
*/
const TokenHandler = ({
redirectPath = "/dashboard",
tokenParamName = "token",
storageKey = "surfsense_bearer_token",
}: TokenHandlerProps) => {
// Always show loading for this component - spinner animation won't reset
useGlobalLoadingEffect(true);
@ -45,7 +42,6 @@ const TokenHandler = ({
}
sessionStorage.removeItem("login_success_tracked");
localStorage.setItem(storageKey, token);
setBearerToken(token);
if (refreshToken) {
@ -78,7 +74,7 @@ const TokenHandler = ({
};
run();
}, [tokenParamName, storageKey, redirectPath]);
}, [tokenParamName, redirectPath]);
// Return null - the global provider handles the loading UI
return null;

View file

@ -4,6 +4,7 @@ import {
AuiIf,
ErrorPrimitive,
MessagePrimitive,
type ToolCallMessagePartComponent,
useAui,
useAuiState,
} from "@assistant-ui/react";
@ -36,7 +37,6 @@ 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";
import { CommentPanelContainer } from "@/components/chat-comments/comment-panel-container/comment-panel-container";
import { CommentSheet } from "@/components/chat-comments/comment-sheet/comment-sheet";
@ -99,146 +99,6 @@ const GenerateImageToolUI = dynamic(
import("@/components/tool-ui/generate-image").then((m) => ({ default: m.GenerateImageToolUI })),
{ ssr: false }
);
const UpdateMemoryToolUI = dynamic(
() => import("@/components/tool-ui/user-memory").then((m) => ({ default: m.UpdateMemoryToolUI })),
{ ssr: false }
);
const SandboxExecuteToolUI = dynamic(
() =>
import("@/components/tool-ui/sandbox-execute").then((m) => ({
default: m.SandboxExecuteToolUI,
})),
{ ssr: false }
);
const CreateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.CreateNotionPageToolUI })),
{ ssr: false }
);
const UpdateNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.UpdateNotionPageToolUI })),
{ ssr: false }
);
const DeleteNotionPageToolUI = dynamic(
() => import("@/components/tool-ui/notion").then((m) => ({ default: m.DeleteNotionPageToolUI })),
{ ssr: false }
);
const CreateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.CreateLinearIssueToolUI })),
{ ssr: false }
);
const UpdateLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.UpdateLinearIssueToolUI })),
{ ssr: false }
);
const DeleteLinearIssueToolUI = dynamic(
() => import("@/components/tool-ui/linear").then((m) => ({ default: m.DeleteLinearIssueToolUI })),
{ ssr: false }
);
const CreateGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.CreateGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const DeleteGoogleDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/google-drive").then((m) => ({
default: m.DeleteGoogleDriveFileToolUI,
})),
{ ssr: false }
);
const CreateOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.CreateOneDriveFileToolUI })),
{ ssr: false }
);
const DeleteOneDriveFileToolUI = dynamic(
() =>
import("@/components/tool-ui/onedrive").then((m) => ({ default: m.DeleteOneDriveFileToolUI })),
{ ssr: false }
);
const CreateDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.CreateDropboxFileToolUI })),
{ ssr: false }
);
const DeleteDropboxFileToolUI = dynamic(
() =>
import("@/components/tool-ui/dropbox").then((m) => ({ default: m.DeleteDropboxFileToolUI })),
{ ssr: false }
);
const CreateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.CreateCalendarEventToolUI,
})),
{ ssr: false }
);
const UpdateCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.UpdateCalendarEventToolUI,
})),
{ ssr: false }
);
const DeleteCalendarEventToolUI = dynamic(
() =>
import("@/components/tool-ui/google-calendar").then((m) => ({
default: m.DeleteCalendarEventToolUI,
})),
{ ssr: false }
);
const CreateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.CreateGmailDraftToolUI })),
{ ssr: false }
);
const UpdateGmailDraftToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.UpdateGmailDraftToolUI })),
{ ssr: false }
);
const SendGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.SendGmailEmailToolUI })),
{ ssr: false }
);
const TrashGmailEmailToolUI = dynamic(
() => import("@/components/tool-ui/gmail").then((m) => ({ default: m.TrashGmailEmailToolUI })),
{ ssr: false }
);
const CreateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.CreateJiraIssueToolUI })),
{ ssr: false }
);
const UpdateJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.UpdateJiraIssueToolUI })),
{ ssr: false }
);
const DeleteJiraIssueToolUI = dynamic(
() => import("@/components/tool-ui/jira").then((m) => ({ default: m.DeleteJiraIssueToolUI })),
{ ssr: false }
);
const CreateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.CreateConfluencePageToolUI,
})),
{ ssr: false }
);
const UpdateConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.UpdateConfluencePageToolUI,
})),
{ ssr: false }
);
const DeleteConfluencePageToolUI = dynamic(
() =>
import("@/components/tool-ui/confluence").then((m) => ({
default: m.DeleteConfluencePageToolUI,
})),
{ ssr: false }
);
function extractDomain(url: string): string | undefined {
try {
return new URL(url).hostname.replace(/^www\./, "");
@ -502,6 +362,27 @@ const MessageInfoDropdown: FC = () => {
);
};
/**
* Tools rendered in the message BODY value-add deliverables only.
*
* Process tools (connector CRUD, sandbox execute, memory updates,
* etc.) are NOT here; they render in the timeline via the slice's
* tool registry (see ``features/chat-messages/timeline``). The body
* opts out of every other tool by registering ``NullBodyTool`` as the
* fallback any tool name not in this map renders nothing in the
* body and is picked up by the timeline instead.
*/
const BODY_TOOLS = {
generate_report: GenerateReportToolUI,
generate_resume: GenerateResumeToolUI,
generate_podcast: GeneratePodcastToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
} as const;
const NullBodyTool: ToolCallMessagePartComponent = () => null;
const AssistantMessageInner: FC = () => {
const isMobile = !useMediaQuery("(min-width: 768px)");
@ -513,47 +394,8 @@ const AssistantMessageInner: FC = () => {
Text: MarkdownText,
Reasoning: ReasoningMessagePart,
tools: {
by_name: {
generate_report: GenerateReportToolUI,
generate_resume: GenerateResumeToolUI,
generate_podcast: GeneratePodcastToolUI,
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
update_memory: UpdateMemoryToolUI,
execute: SandboxExecuteToolUI,
execute_code: SandboxExecuteToolUI,
create_notion_page: CreateNotionPageToolUI,
update_notion_page: UpdateNotionPageToolUI,
delete_notion_page: DeleteNotionPageToolUI,
create_linear_issue: CreateLinearIssueToolUI,
update_linear_issue: UpdateLinearIssueToolUI,
delete_linear_issue: DeleteLinearIssueToolUI,
create_google_drive_file: CreateGoogleDriveFileToolUI,
delete_google_drive_file: DeleteGoogleDriveFileToolUI,
create_onedrive_file: CreateOneDriveFileToolUI,
delete_onedrive_file: DeleteOneDriveFileToolUI,
create_dropbox_file: CreateDropboxFileToolUI,
delete_dropbox_file: DeleteDropboxFileToolUI,
create_calendar_event: CreateCalendarEventToolUI,
update_calendar_event: UpdateCalendarEventToolUI,
delete_calendar_event: DeleteCalendarEventToolUI,
create_gmail_draft: CreateGmailDraftToolUI,
update_gmail_draft: UpdateGmailDraftToolUI,
send_gmail_email: SendGmailEmailToolUI,
trash_gmail_email: TrashGmailEmailToolUI,
create_jira_issue: CreateJiraIssueToolUI,
update_jira_issue: UpdateJiraIssueToolUI,
delete_jira_issue: DeleteJiraIssueToolUI,
create_confluence_page: CreateConfluencePageToolUI,
update_confluence_page: UpdateConfluencePageToolUI,
delete_confluence_page: DeleteConfluencePageToolUI,
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
},
Fallback: ToolFallback,
by_name: BODY_TOOLS,
Fallback: NullBodyTool,
},
}}
/>

View file

@ -1,5 +1,6 @@
"use client";
import { Folder as FolderIcon } from "lucide-react";
import type { PlateElementProps } from "platejs/react";
import {
createPlatePlugin,
@ -9,23 +10,51 @@ import {
usePlateEditor,
} from "platejs/react";
import { type FC, forwardRef, useCallback, useImperativeHandle, useMemo, useRef } from "react";
import { FOLDER_MENTION_DOCUMENT_TYPE } from "@/atoms/chat/mentioned-documents.atom";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document } from "@/contracts/types/document.types";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
export type MentionKind = "doc" | "folder";
export interface MentionedDocument {
id: number;
title: string;
document_type?: string;
kind: MentionKind;
}
/**
* Input shape for inserting a chip. ``kind`` defaults to ``"doc"``
* when omitted so legacy callers don't have to thread the
* discriminator. Folder callers pass ``kind: "folder"`` and the
* folder ``id`` and ``title``; ``document_type`` defaults to
* ``FOLDER_MENTION_DOCUMENT_TYPE`` inside ``insertMentionChip`` so the
* dedup key (`kind:document_type:id`) never collides with a doc chip
* that happens to share an id.
*/
export type MentionChipInput = {
id: number;
title: string;
document_type?: string;
kind?: MentionKind;
};
export interface InlineMentionEditorRef {
focus: () => void;
clear: () => void;
setText: (text: string) => void;
getText: () => string;
getMentionedDocuments: () => MentionedDocument[];
insertMentionChip: (
mention: MentionChipInput,
options?: { removeTriggerText?: boolean }
) => void;
/**
* @deprecated Use ``insertMentionChip``. Kept for one transition
* cycle so we don't break ad-hoc callers; prefer the new name.
*/
insertDocumentChip: (
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
@ -61,6 +90,13 @@ type MentionElementNode = {
id: number;
title: string;
document_type?: string;
/**
* Discriminator added so a folder chip and a doc chip with the
* same id round-trip cleanly through ``getMentionedDocuments``
* and the persisted ``mentioned-documents`` content part.
* Defaults to ``"doc"`` for nodes that predate this field.
*/
kind?: MentionKind;
statusLabel?: string | null;
statusKind?: MentionStatusKind;
children: [{ text: "" }];
@ -90,11 +126,17 @@ const MentionElement: FC<PlateElementProps<MentionElementNode>> = ({
? "text-emerald-700"
: "text-amber-700";
const isFolder = element.kind === "folder";
return (
<span {...attributes} className="inline-flex align-middle">
<span contentEditable={false} className={`${MENTION_CHIP_CLASSNAME} cursor-default`}>
<span className={MENTION_CHIP_ICON_CLASSNAME}>
{getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")}
{isFolder ? (
<FolderIcon className="h-3 w-3" />
) : (
getConnectorIcon(element.document_type ?? "UNKNOWN", "h-3 w-3")
)}
</span>
<span className={MENTION_CHIP_TITLE_CLASSNAME} title={element.title}>
{element.title}
@ -153,10 +195,12 @@ function getMentionedDocuments(value: ComposerValue): MentionedDocument[] {
for (const block of value) {
for (const node of block.children) {
if (!isMentionNode(node)) continue;
const kind: MentionKind = node.kind ?? "doc";
const doc: MentionedDocument = {
id: node.id,
title: node.title,
document_type: node.document_type,
kind,
};
map.set(getMentionDocKey(doc), doc);
}
@ -311,21 +355,23 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor, emitState]
);
const insertDocumentChip = useCallback(
(
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => {
if (typeof doc.id !== "number" || typeof doc.title !== "string") return;
const insertMentionChip = useCallback(
(mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => {
if (typeof mention.id !== "number" || typeof mention.title !== "string") return;
const removeTriggerText = options?.removeTriggerText ?? true;
const current = getCurrentValue();
const selection = editor.selection;
const kind: MentionKind = mention.kind ?? "doc";
const document_type =
mention.document_type ??
(kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
const mentionNode: MentionElementNode = {
type: MENTION_TYPE,
id: doc.id,
title: doc.title,
document_type: doc.document_type,
id: mention.id,
title: mention.title,
document_type,
kind,
children: [{ text: "" }],
};
@ -385,6 +431,19 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
[editor.selection, focusAtEnd, getCurrentValue, setValue]
);
// Backwards-compatible shim — pre-folder callers pass a doc-only
// payload; we route them through ``insertMentionChip`` with
// ``kind: "doc"``.
const insertDocumentChip = useCallback(
(
doc: Pick<Document, "id" | "title" | "document_type">,
options?: { removeTriggerText?: boolean }
) => {
insertMentionChip({ ...doc, kind: "doc" }, options);
},
[insertMentionChip]
);
const removeDocumentChip = useCallback(
(docId: number, docType?: string) => {
const current = getCurrentValue();
@ -460,6 +519,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
setText,
getText,
getMentionedDocuments: getMentionedDocs,
insertMentionChip,
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,
@ -468,6 +528,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
clear,
getMentionedDocs,
getText,
insertMentionChip,
insertDocumentChip,
removeDocumentChip,
setDocumentChipStatus,

View file

@ -8,7 +8,7 @@ import {
useIsMarkdownCodeBlock,
} from "@assistant-ui/react-markdown";
import { useSetAtom } from "jotai";
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, FileIcon, Folder as FolderIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useParams } from "next/navigation";
import { useTheme } from "next-themes";
@ -18,6 +18,7 @@ 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 { MentionChip } from "@/components/assistant-ui/mention-chip";
import "katex/dist/katex.min.css";
import { toast } from "sonner";
import { processChildrenWithCitations } from "@/components/citations/citation-renderer";
@ -33,6 +34,7 @@ import {
import { useElectronAPI } from "@/hooks/use-platform";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
import { getVirtualPathDisplay } from "@/lib/chat/virtual-path-display";
import { cn } from "@/lib/utils";
function MarkdownCodeBlockSkeleton() {
@ -219,59 +221,71 @@ function FilePathLink({ path, className }: { path: string; className?: string })
? 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;
}
const { displayName, isFolder } = getVirtualPathDisplay(path);
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : (
<FileIcon className="size-3.5" />
);
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.");
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
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.
}
}
})();
}}
title="Open in editor panel"
>
{path}
</button>
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.");
}
})();
},
[electronAPI, openEditorPanel, path, resolvedSearchSpaceId]
);
// Folders cannot open in the editor panel — keep them as visual chips.
const onClick = isFolder ? undefined : handleClick;
return (
<MentionChip
icon={icon}
label={displayName || path}
tooltip={path}
onClick={onClick}
ariaLabel={isFolder ? `Folder ${displayName}` : `Open ${displayName}`}
className={className}
/>
);
}

View file

@ -0,0 +1,92 @@
"use client";
import type { MouseEventHandler, ReactNode } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
/**
* A single, minimal chip-button used in two places:
*
* 1. User-message mention chips (rendered for every `@`-mention the user
* inserted in the composer).
* 2. AI-answer file/folder paths (rendered when the assistant emits
* `/documents/.../file.xml` or `/<mount>/.../file.ext`).
*
* Both contexts want the same visual language: a compact, button-styled
* chip with an icon, a truncated label, and an optional tooltip. Sharing
* one component keeps the chat surface visually coherent and means a UX
* tweak (radius, hover, icon size) lands in both places at once.
*
* Styling rules (per shadcn skill):
* - Semantic tokens only (`border`, `bg-background`, `bg-accent`,
* `text-foreground`, `text-muted-foreground`). No raw colors.
* - Layout via `gap-*`, never `space-x-*`.
* - `cn()` for conditional classes.
* - No manual `z-index` the tooltip handles its own stacking.
*/
export interface MentionChipProps {
/**
* Visual prefix. Keep this small (e.g. `size-3.5`); the chip controls
* its own height and oversized icons will push the label out of place.
*/
icon: ReactNode;
/** Label shown inside the chip; truncated with `…` past the max width. */
label: string;
/**
* Full title or path shown on hover. Omit to suppress the tooltip
* entirely (e.g. when the label already conveys the full identity).
*/
tooltip?: ReactNode;
/**
* When provided, the chip behaves like a button (focusable, hover
* effect, pointer cursor). Omit for a purely decorative chip.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
disabled?: boolean;
className?: string;
/** Optional override for the accessible name; defaults to `label`. */
ariaLabel?: string;
}
export function MentionChip({
icon,
label,
tooltip,
onClick,
disabled,
className,
ariaLabel,
}: MentionChipProps) {
const isInteractive = Boolean(onClick) && !disabled;
const chip = (
<button
type="button"
onClick={onClick}
disabled={disabled}
aria-label={ariaLabel ?? label}
className={cn(
"inline-flex max-w-[220px] items-center gap-1.5 rounded-md border bg-background px-2 py-0.5 align-middle text-xs font-medium text-foreground leading-5 transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
isInteractive
? "cursor-pointer hover:bg-accent hover:text-accent-foreground"
: "cursor-default",
disabled && "opacity-60",
className
)}
>
<span className="inline-flex shrink-0 text-muted-foreground">{icon}</span>
<span className="truncate">{label}</span>
</button>
);
if (!tooltip) return chip;
return (
<Tooltip>
<TooltipTrigger asChild>{chip}</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs break-all">
{tooltip}
</TooltipContent>
</Tooltip>
);
}

View file

@ -7,8 +7,8 @@ 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).
* Renders the structured `reasoning` part emitted by the backend stream
* (typed reasoning deltas from the chat model).
*
* Behaviour mirrors the existing `ThinkingStepsDisplay`:
* - collapsed by default;

View file

@ -1,175 +0,0 @@
import { makeAssistantDataUI, useAuiState } from "@assistant-ui/react";
import { ChevronRightIcon } from "lucide-react";
import type { FC } from "react";
import { useCallback, useEffect, useState } from "react";
import { ChainOfThoughtItem } from "@/components/prompt-kit/chain-of-thought";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { cn } from "@/lib/utils";
export interface ThinkingStep {
id: string;
title: string;
items: string[];
status: "pending" | "in_progress" | "completed";
}
/**
* Chain of thought display component - single collapsible dropdown design
*/
export const ThinkingStepsDisplay: FC<{ steps: ThinkingStep[]; isThreadRunning?: boolean }> = ({
steps,
isThreadRunning = true,
}) => {
const getEffectiveStatus = useCallback(
(step: ThinkingStep): "pending" | "in_progress" | "completed" => {
if (step.status === "in_progress" && !isThreadRunning) {
return "completed";
}
return step.status;
},
[isThreadRunning]
);
const inProgressStep = steps.find((s) => getEffectiveStatus(s) === "in_progress");
const allCompleted =
steps.length > 0 &&
!isThreadRunning &&
steps.every((s) => getEffectiveStatus(s) === "completed");
const isProcessing = isThreadRunning && !allCompleted;
const [isOpen, setIsOpen] = useState(() => isProcessing);
useEffect(() => {
if (isProcessing) {
setIsOpen(true);
return;
}
if (allCompleted) {
setIsOpen(false);
}
}, [allCompleted, isProcessing]);
if (steps.length === 0) return null;
const getHeaderText = () => {
if (allCompleted) {
return "Reviewed";
}
if (inProgressStep) {
return inProgressStep.title;
}
if (isProcessing) {
return "Processing";
}
return "Reviewed";
};
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"
)}
>
{isProcessing ? (
<TextShimmerLoader text={getHeaderText()} size="sm" />
) : (
<span>{getHeaderText()}</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-3 pl-1">
{steps.map((step, index) => {
const effectiveStatus = getEffectiveStatus(step);
const isLast = index === steps.length - 1;
return (
<div key={step.id} className="relative flex gap-3">
<div className="relative flex flex-col items-center w-2">
{!isLast && (
<div className="absolute left-1/2 top-[15px] -bottom-[7px] w-px -translate-x-1/2 bg-muted-foreground/30" />
)}
<div className="relative z-10 mt-[7px] flex shrink-0 items-center justify-center">
{effectiveStatus === "in_progress" ? (
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-primary/60" />
<span className="relative inline-flex size-2 rounded-full bg-primary" />
</span>
) : (
<span className="size-2 rounded-full bg-muted-foreground/30" />
)}
</div>
</div>
<div className="flex-1 min-w-0 pb-4">
<div
className={cn(
"text-sm leading-5",
effectiveStatus === "in_progress" && "text-foreground font-medium",
effectiveStatus === "completed" && "text-muted-foreground",
effectiveStatus === "pending" && "text-muted-foreground/60"
)}
>
{step.title}
</div>
{step.items && step.items.length > 0 && (
<div className="mt-1 space-y-0.5">
{step.items.map((item) => (
<ChainOfThoughtItem key={`${step.id}-${item}`} className="text-xs">
{item}
</ChainOfThoughtItem>
))}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
</div>
);
};
/**
* assistant-ui data UI component that renders thinking steps from message content.
* Registered globally via makeAssistantDataUI renders inside MessagePrimitive.Parts
* at the position of the data part in the content array.
*/
function ThinkingStepsDataRenderer({ data }: { name: string; data: unknown }) {
const isThreadRunning = useAuiState(({ thread }) => thread.isRunning);
const isLastMessage = useAuiState(({ message }) => message?.isLast ?? false);
const isMessageStreaming = isThreadRunning && isLastMessage;
const steps = (data as { steps: ThinkingStep[] } | null)?.steps ?? [];
if (steps.length === 0) return null;
return (
<div className="mb-3 -mx-2 leading-normal">
<ThinkingStepsDisplay steps={steps} isThreadRunning={isMessageStreaming} />
</div>
);
}
export const ThinkingStepsDataUI = makeAssistantDataUI({
name: "thinking-steps",
render: ThinkingStepsDataRenderer,
});

View file

@ -36,7 +36,10 @@ import {
} from "@/atoms/agent-tools/agent-tools.atoms";
import { chatSessionStateAtom } from "@/atoms/chat/chat-session-state.atom";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import {
type MentionedDocumentInfo,
mentionedDocumentsAtom,
} from "@/atoms/chat/mentioned-documents.atom";
import { pendingUserImageDataUrlsAtom } from "@/atoms/chat/pending-user-images.atom";
import {
clearPremiumAlertForThreadAtom,
@ -93,7 +96,6 @@ import {
getToolDisplayName,
getToolIcon,
} from "@/contracts/enums/toolIcons";
import type { Document } from "@/contracts/types/document.types";
import { useBatchCommentsPreload } from "@/hooks/use-comments";
import { useCommentsSync } from "@/hooks/use-comments-sync";
import { useMediaQuery } from "@/hooks/use-media-query";
@ -383,9 +385,7 @@ const Composer: FC = () => {
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const editorRef = useRef<InlineMentionEditorRef>(null);
const prevMentionedDocsRef = useRef<
Map<string, Pick<Document, "id" | "title" | "document_type">>
>(new Map());
const prevMentionedDocsRef = useRef<Map<string, MentionedDocumentInfo>>(new Map());
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const { search_space_id, chat_id } = useParams();
@ -628,20 +628,20 @@ const Composer: FC = () => {
);
const handleDocumentsMention = useCallback(
(documents: Pick<Document, "id" | "title" | "document_type">[]) => {
(mentions: MentionedDocumentInfo[]) => {
const editorMentionedDocs = editorRef.current?.getMentionedDocuments() ?? [];
const editorDocKeys = new Set(editorMentionedDocs.map((doc) => getMentionDocKey(doc)));
for (const doc of documents) {
const key = getMentionDocKey(doc);
for (const mention of mentions) {
const key = getMentionDocKey(mention);
if (editorDocKeys.has(key)) continue;
editorRef.current?.insertDocumentChip(doc);
editorRef.current?.insertMentionChip(mention);
}
setMentionedDocuments((prev) => {
const existingKeySet = new Set(prev.map((d) => getMentionDocKey(d)));
const uniqueNewDocs = documents.filter((doc) => !existingKeySet.has(getMentionDocKey(doc)));
return [...prev, ...uniqueNewDocs];
const uniqueNew = mentions.filter((m) => !existingKeySet.has(getMentionDocKey(m)));
return [...prev, ...uniqueNew];
});
setMentionQuery("");
@ -663,7 +663,7 @@ const Composer: FC = () => {
for (const [key, doc] of nextDocsMap) {
if (prevDocsMap.has(key) || editorKeys.has(key)) continue;
editor.insertDocumentChip(doc, { removeTriggerText: false });
editor.insertMentionChip(doc, { removeTriggerText: false });
}
for (const [key, doc] of prevDocsMap) {

View file

@ -1,512 +0,0 @@
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 {
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";
/**
* 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>
);
}
/**
* 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),
[errorData]
);
const serializedResult = useMemo(
() =>
result !== undefined && typeof result !== "string" ? JSON.stringify(result, null, 2) : null,
[result]
);
const cancelledReason =
isCancelled && status.error
? typeof status.error === "string"
? status.error
: serializedError
: null;
const errorReason =
isError && status.error
? typeof status.error === "string"
? status.error
: serializedError
: null;
const displayName = getToolDisplayName(toolName);
const subtitle = errorReason ?? cancelledReason;
return (
<Card
className={cn(
"my-4 max-w-lg overflow-hidden",
isCancelled && "opacity-60",
isError && "border-destructive/30"
)}
>
{/*
``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);
}}
>
{/*
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 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>
{/*
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>
</div>
{/*
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 && (
<>
<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>
</CollapsibleContent>
</Collapsible>
</Card>
);
};
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) {
if (isDoomLoopInterrupt(props.result)) {
return <DoomLoopApprovalToolUI {...props} />;
}
return <GenericHitlApprovalToolUI {...props} />;
}
return <DefaultToolFallbackInner {...props} />;
};

View file

@ -5,12 +5,16 @@ import {
useAuiState,
useMessagePartText,
} from "@assistant-ui/react";
import { useAtomValue } from "jotai";
import { CheckIcon, CopyIcon, Pencil } from "lucide-react";
import { useAtomValue, useSetAtom } from "jotai";
import { CheckIcon, CopyIcon, Folder as FolderIcon, Pencil } from "lucide-react";
import Image from "next/image";
import { type FC, useState } from "react";
import { useParams } from "next/navigation";
import { type FC, useCallback, useState } from "react";
import { toast } from "sonner";
import { currentThreadAtom } from "@/atoms/chat/current-thread.atom";
import { messageDocumentsMapAtom } from "@/atoms/chat/mentioned-documents.atom";
import { openEditorPanelAtom } from "@/atoms/editor/editor-panel.atom";
import { MentionChip } from "@/components/assistant-ui/mention-chip";
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
@ -61,27 +65,61 @@ const UserTextPart: FC = () => {
const text = (part as { text?: string }).text ?? "";
const messageDocumentsMap = useAtomValue(messageDocumentsMapAtom);
const mentionedDocs = (messageId ? messageDocumentsMap[messageId] : undefined) ?? [];
const openEditorPanel = useSetAtom(openEditorPanelAtom);
const params = useParams();
const searchSpaceIdParam = params?.search_space_id;
const parsedSearchSpaceId = Array.isArray(searchSpaceIdParam)
? Number(searchSpaceIdParam[0])
: Number(searchSpaceIdParam);
const resolvedSearchSpaceId = Number.isFinite(parsedSearchSpaceId)
? parsedSearchSpaceId
: undefined;
const handleOpenDoc = useCallback(
(docId: number, title: string) => {
if (!resolvedSearchSpaceId) {
toast.error("Cannot open document outside a search space.");
return;
}
openEditorPanel({
kind: "document",
documentId: docId,
searchSpaceId: resolvedSearchSpaceId,
title,
});
},
[openEditorPanel, resolvedSearchSpaceId]
);
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>
<p style={{ whiteSpace: "pre-line" }} className="wrap-break-word">
{segments.map((segment) => {
if (segment.type === "text") {
return <span key={`txt-${segment.start}`}>{segment.value}</span>;
}
const isFolder = segment.doc.kind === "folder";
const icon = isFolder ? (
<FolderIcon className="size-3.5" />
) : (
<span
getConnectorIcon(segment.doc.document_type ?? "UNKNOWN", "size-3.5")
);
return (
<MentionChip
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>
)
)}
icon={icon}
label={segment.doc.title}
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
onClick={
isFolder
? undefined
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
}
className="mx-0.5"
/>
);
})}
</p>
);
};

View file

@ -176,34 +176,25 @@ export function FolderTreeView({
}, [folders, docsByFolder, foldersByParent, effectiveActiveTypes, searchQuery]);
const folderSelectionStates = useMemo(() => {
// One folder = one chip. The checkbox now reflects whether the
// folder itself is mentioned, not whether every nested doc is —
// that reverses the old subtree-fanout semantics in
// ``DocumentsSidebar.handleToggleFolderSelect``. We keep the
// ``"all" | "some" | "none"`` tri-state on the type so the
// existing ``FolderNode`` UI (which renders an indeterminate
// glyph for ``"some"``) stays compatible, but only ``"all"``
// and ``"none"`` are used in practice.
const states: Record<number, FolderSelectionState> = {};
const isSelectable = (d: DocumentNodeDoc) =>
d.status?.state !== "pending" && d.status?.state !== "processing";
function compute(folderId: number): { selected: number; total: number } {
const directDocs = (docsByFolder[folderId] ?? []).filter(isSelectable);
let selected = directDocs.filter((d) => mentionedDocKeys.has(getMentionDocKey(d))).length;
let total = directDocs.length;
for (const child of foldersByParent[folderId] ?? []) {
const sub = compute(child.id);
selected += sub.selected;
total += sub.total;
}
if (total === 0) states[folderId] = "none";
else if (selected === total) states[folderId] = "all";
else if (selected > 0) states[folderId] = "some";
else states[folderId] = "none";
return { selected, total };
}
for (const f of folders) {
if (states[f.id] === undefined) compute(f.id);
const folderMentionKey = getMentionDocKey({
id: f.id,
document_type: "FOLDER",
kind: "folder",
});
states[f.id] = mentionedDocKeys.has(folderMentionKey) ? "all" : "none";
}
return states;
}, [folders, docsByFolder, foldersByParent, mentionedDocKeys]);
}, [folders, mentionedDocKeys]);
const folderMap = useMemo(() => {
const map: Record<number, FolderDisplay> = {};

View file

@ -11,6 +11,7 @@ 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 { safeDeserializeMarkdown } from "@/components/editor/utils/safe-deserialize";
import { Editor, EditorContainer } from "@/components/ui/editor";
import { preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
@ -169,15 +170,17 @@ export function PlateEditor({
: markdown
? (editor) => {
if (!enableCitations) {
return editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown));
return safeDeserializeMarkdown(
editor,
escapeMdxExpressions(markdown)
) as Value;
}
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const value = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten));
return injectCitationNodes(value as Descendant[], urlMap) as Value;
const value = safeDeserializeMarkdown(
editor,
escapeMdxExpressions(rewritten)
);
return injectCitationNodes(value, urlMap) as Value;
}
: undefined,
});
@ -200,14 +203,13 @@ export function PlateEditor({
let newValue: Descendant[];
if (enableCitations) {
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
const deserialized = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(rewritten)) as Descendant[];
const deserialized = safeDeserializeMarkdown(
editor,
escapeMdxExpressions(rewritten)
);
newValue = injectCitationNodes(deserialized, urlMap);
} else {
newValue = editor
.getApi(MarkdownPlugin)
.markdown.deserialize(escapeMdxExpressions(markdown)) as Descendant[];
newValue = safeDeserializeMarkdown(editor, escapeMdxExpressions(markdown));
}
editor.tf.reset();
editor.tf.setValue(newValue as Value);

View file

@ -0,0 +1,64 @@
// ---------------------------------------------------------------------------
// Safe markdown deserialization for the Plate editor
// ---------------------------------------------------------------------------
// `remark-mdx` treats any HTML-like tag as JSX, so unbalanced inline HTML
// (very common in GitHub READMEs, web-scraped pages, PDF conversions) makes
// it throw "Expected a closing tag for `<a>`" and crash the editor.
//
// Per the MDX maintainers' guidance (mdx-js/mdx, ipikuka/next-mdx-remote-client
// #14), MDX is the wrong format for untrusted markdown and the recommended
// fix is to fall back to plain markdown parsing. `MarkdownPlugin.deserialize`
// accepts a per-call `remarkPlugins` override, so we can:
//
// 1. Try with `remarkMdx` (rich MDX features, e.g. JSX-style components).
// 2. On failure, retry without `remarkMdx` (lenient HTML, like GitHub).
// 3. As a last resort, render the raw source in a paragraph so the user
// never sees a crashed editor.
// ---------------------------------------------------------------------------
import { MarkdownPlugin, remarkMdx } from "@platejs/markdown";
import type { Descendant } from "platejs";
import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import type { PlateEditorInstance } from "@/components/editor/plate-editor";
const STRICT_PLUGINS = [remarkGfm, remarkMath, remarkMdx];
const LENIENT_PLUGINS = [remarkGfm, remarkMath];
function plainTextFallback(markdown: string): Descendant[] {
return [
{
type: "p",
children: [{ text: markdown }],
} as unknown as Descendant,
];
}
/**
* Deserialize markdown into a Plate value, gracefully degrading when the
* MDX-strict parser rejects raw HTML. Always returns a renderable value;
* never throws.
*/
export function safeDeserializeMarkdown(
editor: PlateEditorInstance,
markdown: string
): Descendant[] {
const api = editor.getApi(MarkdownPlugin).markdown;
try {
return api.deserialize(markdown, { remarkPlugins: STRICT_PLUGINS }) as Descendant[];
} catch (mdxError) {
if (process.env.NODE_ENV !== "production") {
console.warn(
"[plate-editor] MDX parse failed, retrying without remark-mdx:",
mdxError
);
}
try {
return api.deserialize(markdown, { remarkPlugins: LENIENT_PLUGINS }) as Descendant[];
} catch (fallbackError) {
console.error("[plate-editor] markdown deserialize failed:", fallbackError);
return plainTextFallback(markdown);
}
}
}

View file

@ -10,13 +10,13 @@ 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,
type TokenUsageData,
TokenUsageProvider,
} from "@/components/assistant-ui/token-usage-context";
import { useAnonymousMode } from "@/contexts/anonymous-mode";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import {
addStepSeparator,
addToolCall,
@ -228,7 +228,8 @@ export function FreeChatPage() {
parsed.toolName,
{},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
forceFlush();
break;
@ -245,6 +246,7 @@ export function FreeChatPage() {
args: parsed.input || {},
argsText: finalArgsText,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
} else {
addToolCall(
@ -254,7 +256,8 @@ export function FreeChatPage() {
parsed.toolName,
parsed.input || {},
false,
parsed.langchainToolCallId
parsed.langchainToolCallId,
parsed.metadata
);
updateToolCall(contentPartsState, parsed.toolCallId, {
argsText: finalArgsText,
@ -268,6 +271,7 @@ export function FreeChatPage() {
updateToolCall(contentPartsState, parsed.toolCallId, {
result: parsed.output,
langchainToolCallId: parsed.langchainToolCallId,
metadata: parsed.metadata,
});
forceFlush();
break;
@ -469,7 +473,7 @@ export function FreeChatPage() {
return (
<TokenUsageProvider store={tokenUsageStore}>
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<TimelineDataUI />
<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">

View file

@ -1,405 +0,0 @@
"use client";
import { format } from "date-fns";
import { TagInput, type Tag as TagType } from "emblor";
import { useAtomValue, useSetAtom } from "jotai";
import { CalendarIcon, XIcon } from "lucide-react";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Drawer, DrawerContent, DrawerHandle, DrawerTitle } from "@/components/ui/drawer";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Skeleton } from "@/components/ui/skeleton";
import { Textarea } from "@/components/ui/textarea";
import { useMediaQuery } from "@/hooks/use-media-query";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
{ ssr: false, loading: () => <Skeleton className="h-64 w-full" /> }
);
function parseEmailsToTags(value: string): TagType[] {
if (!value.trim()) return [];
return value
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.map((email, i) => ({ id: `${Date.now()}-${i}`, text: email }));
}
function tagsToEmailString(tags: TagType[]): string {
return tags.map((t) => t.text).join(", ");
}
function EmailsTagField({
id,
value,
onChange,
placeholder,
}: {
id: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
}) {
const [tags, setTags] = useState<TagType[]>(() => parseEmailsToTags(value));
const [activeTagIndex, setActiveTagIndex] = useState<number | null>(null);
const isInitialMount = useRef(true);
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
useEffect(() => {
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeRef.current(tagsToEmailString(tags));
}, [tags]);
const handleSetTags = useCallback((newTags: TagType[] | ((prev: TagType[]) => TagType[])) => {
setTags((prev) => (typeof newTags === "function" ? newTags(prev) : newTags));
}, []);
const handleAddTag = useCallback((text: string) => {
const trimmed = text.trim();
if (!trimmed) return;
setTags((prev) => {
if (prev.some((tag) => tag.text === trimmed)) return prev;
const newTag: TagType = { id: Date.now().toString(), text: trimmed };
return [...prev, newTag];
});
}, []);
return (
<TagInput
id={id}
tags={tags}
setTags={handleSetTags}
placeholder={placeholder ?? "Add email"}
onAddTag={handleAddTag}
styleClasses={{
inlineTagsContainer:
"border border-input rounded-md bg-transparent shadow-xs transition-[color,box-shadow] outline-none focus-within:border-ring p-1 gap-1",
input:
"w-full min-w-[80px] focus-visible:outline-none shadow-none px-2 h-7 text-foreground placeholder:text-muted-foreground bg-transparent text-sm md:text-sm",
tag: {
body: "h-7 relative bg-accent dark:bg-muted/60 border-0 hover:bg-accent/80 dark:hover:bg-muted rounded-md font-medium text-xs text-foreground/80 ps-2 pe-7 flex",
closeButton:
"absolute -inset-y-px -end-px p-0 rounded-e-md flex size-7 transition-colors outline-0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-ring/70 text-foreground hover:text-foreground",
},
}}
activeTagIndex={activeTagIndex}
setActiveTagIndex={setActiveTagIndex}
/>
);
}
function parseDateTimeValue(value: string): { date: Date | undefined; time: string } {
if (!value) return { date: undefined, time: "09:00" };
try {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return { date: undefined, time: "09:00" };
return {
date: d,
time: format(d, "HH:mm"),
};
} catch {
return { date: undefined, time: "09:00" };
}
}
function buildLocalDateTimeString(date: Date | undefined, time: string): string {
if (!date) return "";
const [hours, minutes] = time.split(":").map(Number);
const combined = new Date(date);
combined.setHours(hours ?? 9, minutes ?? 0, 0, 0);
const y = combined.getFullYear();
const m = String(combined.getMonth() + 1).padStart(2, "0");
const d = String(combined.getDate()).padStart(2, "0");
const h = String(combined.getHours()).padStart(2, "0");
const min = String(combined.getMinutes()).padStart(2, "0");
return `${y}-${m}-${d}T${h}:${min}:00`;
}
function DateTimePickerField({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) {
const parsed = useMemo(() => parseDateTimeValue(value), [value]);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(parsed.date);
const [time, setTime] = useState(parsed.time);
const [open, setOpen] = useState(false);
const handleDateSelect = useCallback(
(day: Date | undefined) => {
setSelectedDate(day);
onChange(buildLocalDateTimeString(day, time));
setOpen(false);
},
[time, onChange]
);
const handleTimeChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = e.target.value;
setTime(newTime);
onChange(buildLocalDateTimeString(selectedDate, newTime));
},
[selectedDate, onChange]
);
const displayLabel = selectedDate
? `${format(selectedDate, "MMM d, yyyy")} at ${time}`
: "Pick date & time";
return (
<div className="flex gap-2">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
id={id}
type="button"
className="flex-1 flex items-center gap-2 h-9 rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring"
>
<CalendarIcon className="size-3.5 text-muted-foreground shrink-0" />
<span className={selectedDate ? "text-foreground" : "text-muted-foreground"}>
{displayLabel}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={selectedDate}
onSelect={handleDateSelect}
defaultMonth={selectedDate}
/>
</PopoverContent>
</Popover>
<Input
type="time"
value={time}
onChange={handleTimeChange}
className="w-[120px] text-sm shrink-0 appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none"
/>
</div>
);
}
export function HitlEditPanelContent({
title: initialTitle,
content: initialContent,
contentFormat,
extraFields,
onSave,
onClose,
showCloseButton = true,
}: {
title: string;
content: string;
toolName: string;
contentFormat?: "markdown" | "html";
extraFields?: ExtraField[];
onSave: (title: string, content: string, extraFieldValues?: Record<string, string>) => void;
onClose?: () => void;
showCloseButton?: boolean;
}) {
const [editedTitle, setEditedTitle] = useState(initialTitle);
const contentRef = useRef(initialContent);
const [isSaving, setIsSaving] = useState(false);
const [extraFieldValues, setExtraFieldValues] = useState<Record<string, string>>(() => {
if (!extraFields) return {};
const initial: Record<string, string> = {};
for (const field of extraFields) {
initial[field.key] = field.value;
}
return initial;
});
const handleContentChange = useCallback((content: string) => {
contentRef.current = content;
}, []);
const handleExtraFieldChange = useCallback((key: string, value: string) => {
setExtraFieldValues((prev) => ({ ...prev, [key]: value }));
}, []);
const handleSave = useCallback(() => {
if (!editedTitle.trim()) return;
setIsSaving(true);
const extras = extraFields && extraFields.length > 0 ? extraFieldValues : undefined;
onSave(editedTitle, contentRef.current, extras);
onClose?.();
}, [editedTitle, onSave, onClose, extraFields, extraFieldValues]);
return (
<>
<div className="flex items-center gap-2 px-4 py-2 shrink-0 border-b">
<input
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
placeholder="Untitled"
className="flex-1 min-w-0 bg-transparent text-sm font-semibold text-foreground outline-none placeholder:text-muted-foreground"
aria-label="Page title"
/>
{onClose && showCloseButton && (
<Button variant="ghost" size="icon" onClick={onClose} className="size-7 shrink-0">
<XIcon className="size-4" />
<span className="sr-only">Close panel</span>
</Button>
)}
</div>
{extraFields && extraFields.length > 0 && (
<div className="flex flex-col gap-3 px-4 py-3 border-b">
{extraFields.map((field) => (
<div key={field.key} className="flex flex-col gap-1.5">
<Label
htmlFor={`extra-field-${field.key}`}
className="text-xs font-medium text-muted-foreground"
>
{field.label}
</Label>
{field.type === "emails" ? (
<EmailsTagField
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(v) => handleExtraFieldChange(field.key, v)}
placeholder={`Add ${field.label.toLowerCase()}`}
/>
) : field.type === "datetime-local" ? (
<DateTimePickerField
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(v) => handleExtraFieldChange(field.key, v)}
/>
) : field.type === "textarea" ? (
<Textarea
id={`extra-field-${field.key}`}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm min-h-[60px]"
/>
) : (
<Input
id={`extra-field-${field.key}`}
type={field.type}
value={extraFieldValues[field.key] ?? ""}
onChange={(e) => handleExtraFieldChange(field.key, e.target.value)}
className="text-sm"
/>
)}
</div>
))}
</div>
)}
<div className="flex-1 overflow-hidden">
<PlateEditor
{...(contentFormat === "html"
? { html: initialContent, onHtmlChange: handleContentChange }
: { markdown: initialContent, onMarkdownChange: handleContentChange })}
readOnly={false}
preset="full"
placeholder="Start writing..."
editorVariant="default"
defaultEditing
onSave={handleSave}
hasUnsavedChanges
isSaving={isSaving}
className="[&_[role=toolbar]]:!bg-sidebar"
/>
</div>
</>
);
}
function DesktopHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.isOpen || !panelState.onSave) return null;
return (
<div className="flex w-[50%] max-w-[700px] min-w-[380px] flex-col border-l bg-sidebar text-sidebar-foreground animate-in slide-in-from-right-4 duration-300 ease-out">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
/>
</div>
);
}
function MobileHitlEditDrawer() {
const panelState = useAtomValue(hitlEditPanelAtom);
const closePanel = useSetAtom(closeHitlEditPanelAtom);
if (!panelState.onSave) return null;
return (
<Drawer
open={panelState.isOpen}
onOpenChange={(open) => {
if (!open) closePanel();
}}
shouldScaleBackground={false}
>
<DrawerContent
className="h-[90vh] max-h-[90vh] z-80 bg-sidebar overflow-hidden"
overlayClassName="z-80"
>
<DrawerHandle />
<DrawerTitle className="sr-only">Edit {panelState.toolName}</DrawerTitle>
<div className="min-h-0 flex-1 flex flex-col overflow-hidden">
<HitlEditPanelContent
title={panelState.title}
content={panelState.content}
toolName={panelState.toolName}
contentFormat={panelState.contentFormat}
extraFields={panelState.extraFields}
onSave={panelState.onSave}
onClose={closePanel}
showCloseButton={false}
/>
</div>
</DrawerContent>
</Drawer>
);
}
export function HitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (!panelState.isOpen) return null;
if (isDesktop) {
return <DesktopHitlEditPanel />;
}
return <MobileHitlEditDrawer />;
}
export function MobileHitlEditPanel() {
const panelState = useAtomValue(hitlEditPanelAtom);
const isDesktop = useMediaQuery("(min-width: 1024px)");
if (isDesktop || !panelState.isOpen) return null;
return <MobileHitlEditDrawer />;
}

View file

@ -37,7 +37,7 @@ export const Navbar = ({ scrolledBgClassName }: NavbarProps = {}) => {
const navItems = [
{ name: "Free\u00A0AI", link: "/free" },
{ name: "Pricing", link: "/pricing" },
{ name: "Blog", link: "/blog" },
// { name: "Blog", link: "/blog" },
{ name: "Changelog", link: "/changelog" },
{ name: "Docs", link: "/docs" },
{ name: "Contact\u00A0Us", link: "/contact" },

View file

@ -68,6 +68,7 @@ function UseCaseCard({
<div
role="button"
tabIndex={0}
aria-label={`Expand ${title}`}
className="cursor-pointer overflow-hidden bg-neutral-50 p-2 dark:bg-neutral-950"
onClick={open}
onKeyDown={(e) => {

View file

@ -4,7 +4,6 @@ import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { PanelRight } from "lucide-react";
import dynamic from "next/dynamic";
import { startTransition, useEffect } from "react";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { closeReportPanelAtom, reportPanelAtom } from "@/atoms/chat/report-panel.atom";
import { citationPanelAtom, closeCitationPanelAtom } from "@/atoms/citation/citation-panel.atom";
import { documentsSidebarOpenAtom } from "@/atoms/documents/ui.atoms";
@ -12,6 +11,7 @@ import { closeEditorPanelAtom, editorPanelAtom } from "@/atoms/editor/editor-pan
import { rightPanelCollapsedAtom, rightPanelTabAtom } from "@/atoms/layout/right-panel.atom";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { closeHitlEditPanelAtom, hitlEditPanelAtom } from "@/features/chat-messages/hitl";
import { DocumentsSidebar } from "../sidebar";
const EditorPanelContent = dynamic(
@ -32,7 +32,7 @@ const CitationPanelContent = dynamic(
const HitlEditPanelContent = dynamic(
() =>
import("@/components/hitl-edit-panel/hitl-edit-panel").then((m) => ({
import("@/features/chat-messages/hitl").then((m) => ({
default: m.HitlEditPanelContent,
})),
{ ssr: false, loading: () => null }

View file

@ -24,7 +24,10 @@ import type React from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
import { mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
import {
makeFolderMention,
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";
@ -881,7 +884,7 @@ function AuthenticatedDocumentsSidebarBase({
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
const key = getMentionDocKey(doc);
const key = getMentionDocKey({ ...doc, kind: "doc" });
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
} else {
@ -889,7 +892,12 @@ function AuthenticatedDocumentsSidebarBase({
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
{
id: doc.id,
title: doc.title,
document_type: doc.document_type as DocumentTypeEnum,
kind: "doc",
},
];
});
}
@ -899,40 +907,29 @@ function AuthenticatedDocumentsSidebarBase({
const handleToggleFolderSelect = useCallback(
(folderId: number, selectAll: boolean) => {
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
const directDocs = (treeDocuments ?? []).filter(
(d) =>
d.folderId === parentId &&
d.status?.state !== "pending" &&
d.status?.state !== "processing" &&
d.status?.state !== "failed"
);
const childFolders = foldersByParent[String(parentId)] ?? [];
const descendantDocs = childFolders.flatMap((cf) => collectSubtreeDocs(cf.id));
return [...directDocs, ...descendantDocs];
}
const subtreeDocs = collectSubtreeDocs(folderId);
if (subtreeDocs.length === 0) return;
// One folder click = one folder-mention chip. The agent
// resolves the chip to its virtual path
// (``/documents/MyFolder/``) and walks it itself with
// ``ls`` / ``find_documents``. We deliberately don't
// fan out to per-doc chips anymore — the previous
// behaviour created N chips for one click and dropped
// nested folders entirely once selected, which the
// agent had no way to recover.
const folder = treeFolders.find((f) => f.id === folderId);
if (!folder) return;
const chip = makeFolderMention({ id: folder.id, name: folder.name });
const chipKey = getMentionDocKey(chip);
if (selectAll) {
setSidebarDocs((prev) => {
const existingDocKeys = new Set(prev.map((d) => getMentionDocKey(d)));
const newDocs = subtreeDocs
.filter((d) => !existingDocKeys.has(getMentionDocKey(d)))
.map((d) => ({
id: d.id,
title: d.title,
document_type: d.document_type as DocumentTypeEnum,
}));
return newDocs.length > 0 ? [...prev, ...newDocs] : prev;
const exists = prev.some((d) => getMentionDocKey(d) === chipKey);
return exists ? prev : [...prev, chip];
});
} else {
const keysToRemove = new Set(subtreeDocs.map((d) => getMentionDocKey(d)));
setSidebarDocs((prev) => prev.filter((d) => !keysToRemove.has(getMentionDocKey(d))));
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== chipKey));
}
},
[treeDocuments, foldersByParent, setSidebarDocs]
[treeFolders, setSidebarDocs]
);
const searchFilteredDocuments = useMemo(() => {
@ -1604,7 +1601,7 @@ function AnonymousDocumentsSidebar({
const handleToggleChatMention = useCallback(
(doc: { id: number; title: string; document_type: string }, isMentioned: boolean) => {
const key = getMentionDocKey(doc);
const key = getMentionDocKey({ ...doc, kind: "doc" });
if (isMentioned) {
setSidebarDocs((prev) => prev.filter((d) => getMentionDocKey(d) !== key));
} else {
@ -1612,7 +1609,12 @@ function AnonymousDocumentsSidebar({
if (prev.some((d) => getMentionDocKey(d) === key)) return prev;
return [
...prev,
{ id: doc.id, title: doc.title, document_type: doc.document_type as DocumentTypeEnum },
{
id: doc.id,
title: doc.title,
document_type: doc.document_type as DocumentTypeEnum,
kind: "doc",
},
];
});
}

View file

@ -1,6 +1,8 @@
"use client";
import { useQuery as useZeroQuery } from "@rocicorp/zero/react";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { Folder as FolderIcon } from "lucide-react";
import {
forwardRef,
useCallback,
@ -11,11 +13,17 @@ import {
useRef,
useState,
} from "react";
import {
FOLDER_MENTION_DOCUMENT_TYPE,
type MentionedDocumentInfo,
} from "@/atoms/chat/mentioned-documents.atom";
import { Skeleton } from "@/components/ui/skeleton";
import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { Document, SearchDocumentTitlesResponse } from "@/contracts/types/document.types";
import { documentsApiService } from "@/lib/apis/documents-api.service";
import { getMentionDocKey } from "@/lib/chat/mention-doc-key";
import { cn } from "@/lib/utils";
import { queries } from "@/zero/queries";
export interface DocumentMentionPickerRef {
selectHighlighted: () => void;
@ -25,9 +33,9 @@ export interface DocumentMentionPickerRef {
interface DocumentMentionPickerProps {
searchSpaceId: number;
onSelectionChange: (documents: Pick<Document, "id" | "title" | "document_type">[]) => void;
onSelectionChange: (mentions: MentionedDocumentInfo[]) => void;
onDone: () => void;
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
initialSelectedDocuments?: MentionedDocumentInfo[];
externalSearch?: string;
}
@ -89,6 +97,11 @@ export const DocumentMentionPicker = forwardRef<
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
// Folders for this search space — pulled from Zero so the picker
// stays consistent with the documents sidebar (same source of
// truth, automatic updates on rename/delete).
const [zeroFolders] = useZeroQuery(queries.folders.bySpace({ searchSpaceId }));
/**
* Search Strategy:
* - Single character (length === 1): Client-side filtering for instant results
@ -267,21 +280,49 @@ export const DocumentMentionPicker = forwardRef<
[actualDocuments]
);
// Track selected documents with composite key (document_type:id) to prevent cross-type ID collisions
// Folder mention candidates filtered by the current search term.
// Single-char and server-search both use the same client filter
// — folder counts in a workspace are tiny compared to docs, so we
// don't need a paged endpoint. Empty search shows all folders.
const folderMentions: MentionedDocumentInfo[] = useMemo(() => {
const all = (zeroFolders ?? []).map((f) => ({
id: f.id,
title: f.name,
document_type: FOLDER_MENTION_DOCUMENT_TYPE,
kind: "folder" as const,
}));
if (!shouldSearch) return all;
const needle = (isSingleCharSearch ? deferredSearch : debouncedSearch).trim().toLowerCase();
if (!needle) return all;
return all.filter((f) => f.title.toLowerCase().includes(needle));
}, [zeroFolders, debouncedSearch, deferredSearch, isSingleCharSearch, shouldSearch]);
// Doc-shape entries reuse their ``document_type`` discriminator;
// folder entries lift the existing kind-aware key so the same
// matchers used by the chip atom apply unchanged.
const selectedKeys = useMemo(
() => new Set(initialSelectedDocuments.map((d) => `${d.document_type}:${d.id}`)),
() =>
new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
[initialSelectedDocuments]
);
// Exclude already-selected documents from keyboard navigation
const selectableDocuments = useMemo(
() => actualDocuments.filter((doc) => !selectedKeys.has(`${doc.document_type}:${doc.id}`)),
[actualDocuments, selectedKeys]
);
// Combined navigation order: SurfSense docs -> User docs -> Folders.
// Mirrors the on-screen ordering so keyboard arrows match what the
// user sees.
const selectableMentions = useMemo<MentionedDocumentInfo[]>(() => {
const docs: MentionedDocumentInfo[] = actualDocuments.map((doc) => ({
id: doc.id,
title: doc.title,
document_type: doc.document_type,
kind: "doc" as const,
}));
const ordered = [...docs, ...folderMentions];
return ordered.filter((m) => !selectedKeys.has(getMentionDocKey(m)));
}, [actualDocuments, folderMentions, selectedKeys]);
const handleSelectDocument = useCallback(
(doc: Pick<Document, "id" | "title" | "document_type">) => {
onSelectionChange([...initialSelectedDocuments, doc]);
const handleSelectMention = useCallback(
(mention: MentionedDocumentInfo) => {
onSelectionChange([...initialSelectedDocuments, mention]);
onDone();
},
[initialSelectedDocuments, onSelectionChange, onDone]
@ -338,42 +379,42 @@ export const DocumentMentionPicker = forwardRef<
ref,
() => ({
selectHighlighted: () => {
if (selectableDocuments[highlightedIndex]) {
handleSelectDocument(selectableDocuments[highlightedIndex]);
if (selectableMentions[highlightedIndex]) {
handleSelectMention(selectableMentions[highlightedIndex]);
}
},
moveUp: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1));
},
moveDown: () => {
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0));
},
}),
[selectableDocuments, highlightedIndex, handleSelectDocument]
[selectableMentions, highlightedIndex, handleSelectMention]
);
// Keyboard navigation handler for arrow keys, Enter, and Escape
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (selectableDocuments.length === 0) return;
if (selectableMentions.length === 0) return;
switch (e.key) {
case "ArrowDown":
e.preventDefault();
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < selectableDocuments.length - 1 ? prev + 1 : 0));
setHighlightedIndex((prev) => (prev < selectableMentions.length - 1 ? prev + 1 : 0));
break;
case "ArrowUp":
e.preventDefault();
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableDocuments.length - 1));
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : selectableMentions.length - 1));
break;
case "Enter":
e.preventDefault();
if (selectableDocuments[highlightedIndex]) {
handleSelectDocument(selectableDocuments[highlightedIndex]);
if (selectableMentions[highlightedIndex]) {
handleSelectMention(selectableMentions[highlightedIndex]);
}
break;
case "Escape":
@ -382,7 +423,7 @@ export const DocumentMentionPicker = forwardRef<
break;
}
},
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
[selectableMentions, highlightedIndex, handleSelectMention, onDone]
);
return (
@ -420,7 +461,7 @@ export const DocumentMentionPicker = forwardRef<
</div>
))}
</div>
) : actualDocuments.length > 0 ? (
) : actualDocuments.length > 0 || folderMentions.length > 0 ? (
<div className="py-1 px-2">
{/* SurfSense Documentation */}
{surfsenseDocsList.length > 0 && (
@ -429,10 +470,16 @@ export const DocumentMentionPicker = forwardRef<
SurfSense Docs
</div>
{surfsenseDocsList.map((doc) => {
const docKey = `${doc.document_type}:${doc.id}`;
const mention: MentionedDocumentInfo = {
id: doc.id,
title: doc.title,
document_type: doc.document_type,
kind: "doc",
};
const docKey = getMentionDocKey(mention);
const isAlreadySelected = selectedKeys.has(docKey);
const selectableIndex = selectableDocuments.findIndex(
(d) => d.document_type === doc.document_type && d.id === doc.id
const selectableIndex = selectableMentions.findIndex(
(m) => getMentionDocKey(m) === docKey
);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
@ -445,7 +492,7 @@ export const DocumentMentionPicker = forwardRef<
}
}}
type="button"
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
@ -480,10 +527,16 @@ export const DocumentMentionPicker = forwardRef<
Your Documents
</div>
{userDocsList.map((doc) => {
const docKey = `${doc.document_type}:${doc.id}`;
const mention: MentionedDocumentInfo = {
id: doc.id,
title: doc.title,
document_type: doc.document_type,
kind: "doc",
};
const docKey = getMentionDocKey(mention);
const isAlreadySelected = selectedKeys.has(docKey);
const selectableIndex = selectableDocuments.findIndex(
(d) => d.document_type === doc.document_type && d.id === doc.id
const selectableIndex = selectableMentions.findIndex(
(m) => getMentionDocKey(m) === docKey
);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
@ -496,7 +549,7 @@ export const DocumentMentionPicker = forwardRef<
}
}}
type="button"
onClick={() => !isAlreadySelected && handleSelectDocument(doc)}
onClick={() => !isAlreadySelected && handleSelectMention(mention)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
@ -521,6 +574,60 @@ export const DocumentMentionPicker = forwardRef<
</>
)}
{/* Folders single source of truth is Zero (same store
that powers the documents sidebar). Selecting a
folder inserts a folder chip whose path the agent
can walk with ``ls`` / ``find_documents``. */}
{folderMentions.length > 0 && (
<>
{(surfsenseDocsList.length > 0 || userDocsList.length > 0) && (
<div className="mx-2 my-4 border-t border-border dark:border-white/5" />
)}
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Folders
</div>
{folderMentions.map((folder) => {
const folderKey = getMentionDocKey(folder);
const isAlreadySelected = selectedKeys.has(folderKey);
const selectableIndex = selectableMentions.findIndex(
(m) => getMentionDocKey(m) === folderKey
);
const isHighlighted = !isAlreadySelected && selectableIndex === highlightedIndex;
return (
<button
key={folderKey}
ref={(el) => {
if (el && selectableIndex >= 0) {
itemRefs.current.set(selectableIndex, el);
}
}}
type="button"
onClick={() => !isAlreadySelected && handleSelectMention(folder)}
onMouseEnter={() => {
if (!isAlreadySelected && selectableIndex >= 0) {
setHighlightedIndex(selectableIndex);
}
}}
disabled={isAlreadySelected}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left transition-colors rounded-md",
isAlreadySelected ? "opacity-50 cursor-not-allowed" : "cursor-pointer",
isHighlighted && "bg-accent"
)}
>
<span className="shrink-0 text-muted-foreground text-sm">
<FolderIcon className="h-4 w-4" />
</span>
<span className="flex-1 text-sm truncate" title={folder.title}>
{folder.title}
</span>
</button>
);
})}
</>
)}
{/* Pagination loading indicator */}
{isLoadingMore && (
<div className="flex items-center justify-center py-2">

View file

@ -2,10 +2,10 @@
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";
import { Spinner } from "@/components/ui/spinner";
import { TimelineDataUI } from "@/features/chat-messages/timeline";
import { usePublicChat } from "@/hooks/use-public-chat";
import { usePublicChatRuntime } from "@/hooks/use-public-chat-runtime";
import { PublicChatFooter } from "./public-chat-footer";
@ -41,7 +41,7 @@ export function PublicChatView({ shareToken }: PublicChatViewProps) {
<main className="min-h-screen bg-main-panel text-foreground overflow-x-hidden">
<Navbar scrolledBgClassName={navbarScrolledBg} />
<AssistantRuntimeProvider runtime={runtime}>
<ThinkingStepsDataUI />
<TimelineDataUI />
<StepSeparatorDataUI />
<div className="flex h-screen pt-16 overflow-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">

View file

@ -5,6 +5,7 @@ import {
AuiIf,
MessagePrimitive,
ThreadPrimitive,
type ToolCallMessagePartComponent,
useAuiState,
} from "@assistant-ui/react";
import { CheckIcon, CopyIcon } from "lucide-react";
@ -14,7 +15,6 @@ 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";
import { GeneratePodcastToolUI } from "@/components/tool-ui/generate-podcast";
@ -29,6 +29,8 @@ const GenerateVideoPresentationToolUI = dynamic(
{ ssr: false }
);
const NullToolUi: ToolCallMessagePartComponent = () => null;
interface PublicThreadProps {
footer?: ReactNode;
}
@ -168,12 +170,8 @@ const PublicAssistantMessage: FC = () => {
generate_video_presentation: GenerateVideoPresentationToolUI,
display_image: GenerateImageToolUI,
generate_image: GenerateImageToolUI,
web_search: () => null,
link_preview: () => null,
multi_link_preview: () => null,
scrape_webpage: () => null,
},
Fallback: ToolFallback,
Fallback: NullToolUi,
},
}}
/>

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface ConfluenceAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
type DeleteConfluencePageInterruptContext = {
account?: {

View file

@ -4,13 +4,16 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
type UpdateConfluencePageInterruptContext = {
account?: {

View file

@ -1,187 +0,0 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, OctagonAlert } from "lucide-react";
import { useCallback, useEffect, useMemo } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
/**
* Specialized HITL card for ``DoomLoopMiddleware`` interrupts. The
* backend signals these by setting ``context.permission === "doom_loop"``
* on the ``permission_ask`` interrupt.
*
* The card replaces the generic "approve/reject" framing with a
* "continue/stop" affordance that better matches the user's mental
* model: the agent is stuck repeating itself, not asking permission
* for a destructive action.
*/
function DoomLoopCard({
toolName,
args,
interruptData,
onDecision,
}: {
toolName: string;
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const context = (interruptData.context ?? {}) as Record<string, unknown>;
const threshold = typeof context.threshold === "number" ? context.threshold : 3;
const stuckTool = (typeof context.tool === "string" && context.tool) || toolName;
const recentSignatures = Array.isArray(context.recent_signatures)
? (context.recent_signatures as string[])
: [];
const displayName = stuckTool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const argPreview = useMemo(() => {
if (!args || Object.keys(args).length === 0) return null;
try {
const json = JSON.stringify(args, null, 2);
return json.length > 600 ? `${json.slice(0, 600)}` : json;
} catch {
return null;
}
}, [args]);
const handleContinue = useCallback(() => {
if (phase !== "pending") return;
setProcessing();
onDecision({ type: "approve" });
}, [phase, setProcessing, onDecision]);
const handleStop = useCallback(() => {
if (phase !== "pending") return;
setRejected();
onDecision({ type: "reject", message: "Doom loop: user requested stop." });
}, [phase, setRejected, onDecision]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (phase !== "pending") return;
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
e.preventDefault();
handleStop();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [phase, handleStop]);
const isResolved = phase === "complete" || phase === "rejected";
return (
<Alert variant={phase === "rejected" ? "default" : "destructive"} className="my-4 max-w-lg">
<OctagonAlert className="size-4" />
<AlertTitle className="flex items-center gap-2">
<span>
{phase === "rejected"
? "Stopped"
: phase === "processing"
? "Continuing…"
: phase === "complete"
? "Continued"
: "I might be stuck"}
</span>
{!isResolved && (
<Badge variant="outline" className="font-mono text-[10px]">
doom-loop
</Badge>
)}
</AlertTitle>
<AlertDescription className="flex flex-col gap-3">
{phase === "processing" ? (
<TextShimmerLoader text="Resuming…" size="sm" />
) : phase === "rejected" ? (
<p className="text-xs">
I stopped retrying <span className="font-medium">{displayName}</span> as you asked.
</p>
) : phase === "complete" ? (
<p className="text-xs">
Continuing to call <span className="font-medium">{displayName}</span> as you asked.
</p>
) : (
<p className="text-xs">
I called <span className="font-medium">{displayName}</span> {threshold} times in a row
with similar arguments. Should I keep going or stop and rethink?
</p>
)}
{argPreview && phase === "pending" && (
<>
<Separator />
<div className="flex flex-col gap-1">
<p className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Last arguments
</p>
<pre className="max-h-32 overflow-auto rounded-md bg-muted/50 p-2 text-[11px] text-foreground/80">
{argPreview}
</pre>
</div>
</>
)}
{recentSignatures.length > 0 && phase === "pending" && (
<details className="text-[11px] text-muted-foreground">
<summary className="cursor-pointer select-none">
Show repeated signatures ({recentSignatures.length})
</summary>
<ul className="mt-1 ml-4 list-disc">
{recentSignatures.map((sig) => (
<li key={sig} className="font-mono break-all">
{sig}
</li>
))}
</ul>
</details>
)}
{phase === "pending" && (
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" className="rounded-lg gap-1.5" onClick={handleStop}>
Stop and rethink
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button size="sm" variant="ghost" onClick={handleContinue}>
Continue anyway
</Button>
</div>
)}
</AlertDescription>
</Alert>
);
}
export const DoomLoopApprovalToolUI: ToolCallMessagePartComponent = ({
toolName,
args,
result,
}) => {
const { dispatch } = useHitlDecision();
if (!result || !isInterruptResult(result)) return null;
return (
<DoomLoopCard
toolName={toolName}
args={args as Record<string, unknown>}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};
export function isDoomLoopInterrupt(result: unknown): boolean {
if (!isInterruptResult(result)) return false;
const ctx = (result.context ?? {}) as Record<string, unknown>;
return ctx.permission === "doom_loop";
}

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface DropboxAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface DropboxAccount {
id: number;

View file

@ -1,263 +0,0 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
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";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
function ParamEditor({
params,
onChange,
disabled,
}: {
params: Record<string, unknown>;
onChange: (updated: Record<string, unknown>) => void;
disabled: boolean;
}) {
const entries = Object.entries(params);
if (entries.length === 0) return null;
return (
<div className="space-y-2">
{entries.map(([key, value]) => {
const strValue = value == null ? "" : String(value);
const isLong = strValue.length > 120;
const fieldId = `hitl-param-${key}`;
return (
<div key={key} className="space-y-1">
<label htmlFor={fieldId} className="text-xs font-medium text-muted-foreground">
{key}
</label>
{isLong ? (
<Textarea
id={fieldId}
value={strValue}
disabled={disabled}
rows={3}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="text-xs"
/>
) : (
<Input
id={fieldId}
value={strValue}
disabled={disabled}
onChange={(e) => onChange({ ...params, [key]: e.target.value })}
className="text-xs"
/>
)}
</div>
);
})}
</div>
);
}
function GenericApprovalCard({
toolName,
args,
interruptData,
onDecision,
}: {
toolName: string;
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [editedParams, setEditedParams] = useState<Record<string, unknown>>(args);
const [isEditing, setIsEditing] = useState(false);
const displayName = getToolDisplayName(toolName);
const mcpServer = interruptData.context?.mcp_server as string | undefined;
const toolDescription = interruptData.context?.tool_description as string | undefined;
const mcpConnectorId = interruptData.context?.mcp_connector_id as number | undefined;
const isMCPTool = mcpConnectorId != null;
const reviewConfig = interruptData.review_configs?.[0];
const allowedDecisions = reviewConfig?.allowed_decisions ?? ["approve", "reject"];
const canEdit = allowedDecisions.includes("edit");
const hasChanged = useMemo(() => {
return JSON.stringify(editedParams) !== JSON.stringify(args);
}, [editedParams, args]);
const handleApprove = useCallback(() => {
if (phase !== "pending") return;
const isEdited = isEditing && hasChanged;
setProcessing();
onDecision({
type: isEdited ? "edit" : "approve",
edited_action: isEdited
? { name: interruptData.action_requests[0]?.name ?? toolName, args: editedParams }
: undefined,
});
}, [
phase,
setProcessing,
isEditing,
hasChanged,
onDecision,
interruptData,
toolName,
editedParams,
]);
const handleAlwaysAllow = useCallback(() => {
if (phase !== "pending" || !isMCPTool) return;
setProcessing();
onDecision({ type: "approve" });
connectorsApiService.trustMCPTool(mcpConnectorId, toolName).catch(() => {
toast.error(
"Failed to save 'Always Allow' preference. The tool will still require approval next time."
);
});
}, [phase, setProcessing, onDecision, isMCPTool, mcpConnectorId, toolName]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey && !e.ctrlKey && !e.metaKey && phase === "pending") {
handleApprove();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [handleApprove, phase]);
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-[box-shadow] duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{phase === "rejected"
? `${displayName} — Rejected`
: phase === "processing" || phase === "complete"
? `${displayName} — Approved`
: displayName}
</p>
{phase === "processing" ? (
<TextShimmerLoader text="Executing..." size="sm" />
) : phase === "complete" ? (
<p className="text-xs text-muted-foreground mt-0.5">Action completed</p>
) : phase === "rejected" ? (
<p className="text-xs text-muted-foreground mt-0.5">Action was cancelled</p>
) : (
<p className="text-xs text-muted-foreground mt-0.5">
Requires your approval to proceed
</p>
)}
{mcpServer && (
<p className="text-[10px] text-muted-foreground/70 mt-1">
via <span className="font-medium">{mcpServer}</span>
</p>
)}
</div>
{phase === "pending" && canEdit && !isEditing && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground -mt-1 -mr-2"
onClick={() => setIsEditing(true)}
>
<Pencil className="size-3.5" />
Edit
</Button>
)}
</div>
{/* Description */}
{toolDescription && phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-3">
<p className="text-xs text-muted-foreground">{toolDescription}</p>
</div>
</>
)}
{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">Inputs</p>
{phase === "pending" && isEditing ? (
<ParamEditor
params={editedParams}
onChange={setEditedParams}
disabled={phase !== "pending"}
/>
) : (
<pre className="text-xs text-foreground/80 whitespace-pre-wrap break-all bg-muted/50 rounded-lg p-3">
{JSON.stringify(args, null, 2)}
</pre>
)}
</div>
</>
)}
{/* Action buttons */}
{phase === "pending" && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button size="sm" className="rounded-lg gap-1.5" onClick={handleApprove}>
{isEditing && hasChanged ? "Approve with edits" : "Approve"}
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
)}
{isMCPTool && (
<Button size="sm" className="rounded-lg" onClick={handleAlwaysAllow}>
Always Allow
</Button>
)}
{allowedDecisions.includes("reject") && (
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setRejected();
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
)}
</div>
</>
)}
</div>
);
}
export const GenericHitlApprovalToolUI: ToolCallMessagePartComponent = ({
toolName,
args,
result,
}) => {
const { dispatch } = useHitlDecision();
if (!result || !isInterruptResult(result)) return null;
return (
<GenericApprovalCard
toolName={toolName}
args={args as Record<string, unknown>}
interruptData={result}
onDecision={(decision) => dispatch([decision])}
/>
);
};

View file

@ -4,8 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -16,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -4,8 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -16,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -4,14 +4,16 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, MailIcon, Pencil, UserIcon, UsersIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GmailAccount {
id: number;

View file

@ -11,8 +11,6 @@ import {
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -23,9 +21,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GoogleCalendarAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface GoogleCalendarAccount {
id: number;

View file

@ -11,14 +11,16 @@ import {
UsersIcon,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import type { ExtraField } from "@/atoms/chat/hitl-edit-panel.atom";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { ExtraField, HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GoogleCalendarAccount {
id: number;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface GoogleDriveAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface GoogleDriveAccount {
id: number;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface JiraAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface JiraAccount {
id: number;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
@ -16,9 +15,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface JiraIssue {
issue_id: string;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
@ -17,9 +16,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface LinearLabel {
id: string;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
type LinearDeleteIssueContext = {
workspace?: { id: number; organization_name: string };

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Badge } from "@/components/ui/badge";
@ -17,9 +16,13 @@ import {
SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface LinearLabel {
id: string;

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
type NotionCreatePageContext = {
accounts?: Array<{

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
type NotionDeletePageContext = {
account?: {

View file

@ -4,13 +4,16 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
type NotionUpdatePageContext = {
account?: {

View file

@ -4,7 +4,6 @@ import type { ToolCallMessagePartProps } from "@assistant-ui/react";
import { useSetAtom } from "jotai";
import { CornerDownLeftIcon, FileIcon, Pencil } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom";
import { PlateEditor } from "@/components/editor/plate-editor";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
@ -15,9 +14,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import {
isInterruptResult,
openHitlEditPanelAtom,
useHitlDecision,
useHitlPhase,
} from "@/features/chat-messages/hitl";
interface OneDriveAccount {
id: number;

View file

@ -6,9 +6,8 @@ import { useCallback, useEffect, useState } from "react";
import { TextShimmerLoader } from "@/components/prompt-kit/loader";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/features/chat-messages/hitl";
import { isInterruptResult, useHitlDecision, useHitlPhase } from "@/features/chat-messages/hitl";
interface OneDriveAccount {
id: number;

View file

@ -118,6 +118,7 @@ function HeroCarouselCard({
<div
role="button"
tabIndex={0}
aria-label={`Expand ${title}`}
className="cursor-pointer bg-neutral-50 p-2 sm:p-3 dark:bg-neutral-950"
onClick={open}
onKeyDown={(e) => {