Merge commit 'e1e4bb4706' into dev_mod

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-04-13 20:35:04 -07:00
commit 5d3142332b
85 changed files with 2357 additions and 3132 deletions

View file

@ -798,7 +798,7 @@ export default function NewChatPage() {
});
} else {
const tcId = `interrupt-${action.name}`;
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args);
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, {
result: { __interrupt__: true, ...interruptData },
});
@ -1125,7 +1125,7 @@ export default function NewChatPage() {
});
} else {
const tcId = `interrupt-${action.name}`;
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args);
addToolCall(contentPartsState, TOOLS_WITH_UI, tcId, action.name, action.args, true);
updateToolCall(contentPartsState, tcId, {
result: {
__interrupt__: true,

View file

@ -78,7 +78,7 @@ export function ProfileContent() {
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border bg-card p-6">
<div className="rounded-lg bg-card">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label>{t("profile_avatar")}</Label>

View file

@ -499,10 +499,14 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const empty = text.length === 0 && mentionedDocs.size === 0;
setIsEmpty(empty);
// Check for @ mentions
// Unified trigger scan: find the leftmost @ or / in the current word.
// Whichever trigger was typed first owns the token — the other character
// is treated as part of the query, not as a separate trigger.
const selection = window.getSelection();
let shouldTriggerMention = false;
let mentionQuery = "";
let shouldTriggerAction = false;
let actionQuery = "";
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
@ -512,63 +516,41 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
// Look for @ before cursor
let atIndex = -1;
let wordStart = 0;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "@") {
atIndex = i;
break;
}
// Stop if we hit a space (@ must be at word boundary)
if (textContent[i] === " " || textContent[i] === "\n") {
wordStart = i + 1;
break;
}
}
if (atIndex !== -1) {
const query = textContent.slice(atIndex + 1, cursorPos);
// Only trigger if query doesn't start with space
let triggerChar: "@" | "/" | null = null;
let triggerIndex = -1;
for (let i = wordStart; i < cursorPos; i++) {
if (textContent[i] === "@" || textContent[i] === "/") {
triggerChar = textContent[i] as "@" | "/";
triggerIndex = i;
break;
}
}
if (triggerChar === "@" && triggerIndex !== -1) {
const query = textContent.slice(triggerIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerMention = true;
mentionQuery = query;
}
}
}
}
// Check for / actions (same pattern as @)
let shouldTriggerAction = false;
let actionQuery = "";
if (!shouldTriggerMention && selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const textNode = range.startContainer;
if (textNode.nodeType === Node.TEXT_NODE) {
const textContent = textNode.textContent || "";
const cursorPos = range.startOffset;
let slashIndex = -1;
for (let i = cursorPos - 1; i >= 0; i--) {
if (textContent[i] === "/") {
slashIndex = i;
break;
}
if (textContent[i] === " " || textContent[i] === "\n") {
break;
}
}
if (
slashIndex !== -1 &&
(slashIndex === 0 ||
textContent[slashIndex - 1] === " " ||
textContent[slashIndex - 1] === "\n")
) {
const query = textContent.slice(slashIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerAction = true;
actionQuery = query;
} else if (triggerChar === "/" && triggerIndex !== -1) {
if (
triggerIndex === 0 ||
textContent[triggerIndex - 1] === " " ||
textContent[triggerIndex - 1] === "\n"
) {
const query = textContent.slice(triggerIndex + 1, cursorPos);
if (!query.startsWith(" ")) {
shouldTriggerAction = true;
actionQuery = query;
}
}
}
}

View file

@ -28,8 +28,7 @@ import {
import { AnimatePresence, motion } from "motion/react";
import Image from "next/image";
import { useParams } from "next/navigation";
import { type FC, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { type FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
agentToolsAtom,
disabledToolsAtom,
@ -124,16 +123,18 @@ const ThreadContent: FC = () => {
}}
/>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="grow" />
</AuiIf>
<ThreadPrimitive.ViewportFooter
className="aui-thread-viewport-footer sticky bottom-0 z-10 mx-auto flex w-full max-w-(--thread-max-width) flex-col gap-4 overflow-visible rounded-t-3xl bg-main-panel pb-4 md:pb-6"
style={{ paddingBottom: "max(1rem, env(safe-area-inset-bottom))" }}
>
<ThreadScrollToBottom />
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<div className="fade-in slide-in-from-bottom-4 animate-in duration-500 ease-out fill-mode-both">
<Composer />
</div>
</AuiIf>
<AuiIf condition={({ thread }) => !thread.isEmpty}>
<Composer />
</AuiIf>
</ThreadPrimitive.ViewportFooter>
</ThreadPrimitive.Viewport>
</ThreadPrimitive.Root>
@ -339,10 +340,7 @@ const Composer: FC = () => {
const [showPromptPicker, setShowPromptPicker] = useState(false);
const [mentionQuery, setMentionQuery] = useState("");
const [actionQuery, setActionQuery] = useState("");
const [containerPos, setContainerPos] = useState({ bottom: "200px", left: "50%", top: "auto" });
const editorRef = useRef<InlineMentionEditorRef>(null);
const editorContainerRef = useRef<HTMLDivElement>(null);
const composerBoxRef = useRef<HTMLDivElement>(null);
const documentPickerRef = useRef<DocumentMentionPickerRef>(null);
const promptPickerRef = useRef<PromptPickerRef>(null);
const viewportRef = useRef<Element | null>(null);
@ -363,38 +361,13 @@ const Composer: FC = () => {
viewportRef.current = document.querySelector(".aui-thread-viewport");
}, []);
// Compute picker positions using ResizeObserver to avoid layout reads during render
useLayoutEffect(() => {
if (!editorContainerRef.current) return;
const updatePosition = () => {
if (!editorContainerRef.current) return;
const rect = editorContainerRef.current.getBoundingClientRect();
const composerRect = composerBoxRef.current?.getBoundingClientRect();
setContainerPos({
bottom: `${window.innerHeight - rect.top + 8}px`,
left: `${rect.left}px`,
top: composerRect ? `${composerRect.bottom + 8}px` : "auto",
});
};
updatePosition();
const ro = new ResizeObserver(updatePosition);
ro.observe(editorContainerRef.current);
if (composerBoxRef.current) {
ro.observe(composerBoxRef.current);
}
return () => ro.disconnect();
}, []);
const electronAPI = useElectronAPI();
const [clipboardInitialText, setClipboardInitialText] = useState<string | undefined>();
const clipboardLoadedRef = useRef(false);
useEffect(() => {
if (!electronAPI || clipboardLoadedRef.current) return;
clipboardLoadedRef.current = true;
electronAPI.getQuickAskText().then((text) => {
electronAPI.getQuickAskText().then((text: string) => {
if (text) {
setClipboardInitialText(text);
}
@ -587,23 +560,15 @@ const Composer: FC = () => {
// Submit message (blocked during streaming, document picker open, or AI responding to another user)
const handleSubmit = useCallback(() => {
if (isThreadRunning || isBlockedByOtherUser) {
return;
}
if (!showDocumentPopover && !showPromptPicker) {
if (clipboardInitialText) {
const userText = editorRef.current?.getText() ?? "";
const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
aui.composer().setText(combined);
setClipboardInitialText(undefined);
}
aui.composer().send();
editorRef.current?.clear();
setMentionedDocuments([]);
setSidebarDocs([]);
}
if (isThreadRunning || isBlockedByOtherUser) return;
if (showDocumentPopover) return;
if (showDocumentPopover || showPromptPicker) return;
if (clipboardInitialText) {
const userText = editorRef.current?.getText() ?? "";
const combined = userText ? `${userText}\n\n${clipboardInitialText}` : clipboardInitialText;
aui.composer().setText(combined);
setClipboardInitialText(undefined);
}
const viewportEl = viewportRef.current;
const heightBefore = viewportEl?.scrollHeight ?? 0;
@ -617,18 +582,14 @@ const Composer: FC = () => {
// assistant message so that scrolling-to-bottom actually positions the
// user message at the TOP of the viewport. That slack height is
// calculated asynchronously (ResizeObserver → style → layout).
//
// We poll via rAF for ~2 s, re-scrolling whenever scrollHeight changes
// (user msg render → assistant placeholder → ViewportSlack min-height →
// first streamed content). Backup setTimeout calls cover cases where
// the batcher's 50 ms throttle delays the DOM update past the rAF.
// Poll via rAF for ~500ms, re-scrolling whenever scrollHeight changes.
const scrollToBottom = () =>
threadViewportStore.getState().scrollToBottom({ behavior: "instant" });
let lastHeight = heightBefore;
let frames = 0;
let cancelled = false;
const POLL_FRAMES = 120;
const POLL_FRAMES = 30;
const pollAndScroll = () => {
if (cancelled) return;
@ -648,16 +609,11 @@ const Composer: FC = () => {
const t1 = setTimeout(scrollToBottom, 100);
const t2 = setTimeout(scrollToBottom, 300);
const t3 = setTimeout(scrollToBottom, 600);
// Cleanup if component unmounts during the polling window. The ref is
// checked inside pollAndScroll; timeouts are cleared in the return below.
// Store cleanup fn so it can be called from a useEffect cleanup if needed.
submitCleanupRef.current = () => {
cancelled = true;
clearTimeout(t1);
clearTimeout(t2);
clearTimeout(t3);
};
}, [
showDocumentPopover,
@ -705,28 +661,54 @@ const Composer: FC = () => {
);
return (
<ComposerPrimitive.Root
className="aui-composer-root relative flex w-full flex-col gap-2"
style={showPromptPicker && clipboardInitialText ? { marginBottom: 220 } : undefined}
>
<ComposerPrimitive.Root className="aui-composer-root relative flex w-full flex-col gap-2">
<ChatSessionStatus
isAiResponding={isAiResponding}
respondingToUserId={respondingToUserId}
currentUserId={currentUser?.id ?? null}
members={members ?? []}
/>
<div
ref={composerBoxRef}
className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow"
>
{showDocumentPopover && (
<div className="absolute bottom-full left-0 z-[9999] mb-2">
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
/>
</div>
)}
{showPromptPicker && (
<div
className={cn(
"absolute left-0 z-[9999]",
clipboardInitialText ? "top-full mt-2" : "bottom-full mb-2"
)}
>
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
/>
</div>
)}
<div className="aui-composer-attachment-dropzone flex w-full flex-col overflow-hidden rounded-2xl border-input bg-muted pt-2 outline-none transition-shadow">
{clipboardInitialText && (
<ClipboardChip
text={clipboardInitialText}
onDismiss={() => setClipboardInitialText(undefined)}
/>
)}
{/* Inline editor with @mention support */}
<div ref={editorContainerRef} className="aui-composer-input-wrapper px-4 pt-3 pb-6">
<div className="aui-composer-input-wrapper px-4 pt-3 pb-6">
<InlineMentionEditor
ref={editorRef}
placeholder={currentPlaceholder}
@ -741,49 +723,6 @@ const Composer: FC = () => {
className="min-h-[24px]"
/>
</div>
{/* Document picker popover (portal to body for proper z-index stacking) */}
{showDocumentPopover &&
typeof document !== "undefined" &&
createPortal(
<DocumentMentionPicker
ref={documentPickerRef}
searchSpaceId={Number(search_space_id)}
onSelectionChange={handleDocumentsMention}
onDone={() => {
setShowDocumentPopover(false);
setMentionQuery("");
}}
initialSelectedDocuments={mentionedDocuments}
externalSearch={mentionQuery}
containerStyle={{
bottom: containerPos.bottom,
left: containerPos.left,
}}
/>,
document.body
)}
{showPromptPicker &&
typeof document !== "undefined" &&
createPortal(
<PromptPicker
ref={promptPickerRef}
onSelect={clipboardInitialText ? handleQuickAskSelect : handleActionSelect}
onDone={() => {
setShowPromptPicker(false);
setActionQuery("");
}}
externalSearch={actionQuery}
containerStyle={{
position: "fixed",
...(clipboardInitialText
? { top: containerPos.top }
: { bottom: containerPos.bottom }),
left: containerPos.left,
zIndex: 50,
}}
/>,
document.body
)}
<ComposerAction isBlockedByOtherUser={isBlockedByOtherUser} />
<ConnectorIndicator showTrigger={false} />
<ConnectToolsBanner isThreadEmpty={isThreadEmpty} />

View file

@ -1,14 +1,16 @@
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, XCircleIcon } from "lucide-react";
import { useMemo, useState } from "react";
import { GenericHitlApprovalToolUI } from "@/components/tool-ui/generic-hitl-approval";
import { getToolIcon } from "@/contracts/enums/toolIcons";
import { isInterruptResult } from "@/lib/hitl";
import { cn } from "@/lib/utils";
function formatToolName(name: string): string {
return name.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
export const ToolFallback: ToolCallMessagePartComponent = ({
const DefaultToolFallbackInner: ToolCallMessagePartComponent = ({
toolName,
argsText,
result,
@ -145,3 +147,10 @@ export const ToolFallback: ToolCallMessagePartComponent = ({
</div>
);
};
export const ToolFallback: ToolCallMessagePartComponent = (props) => {
if (isInterruptResult(props.result)) {
return <GenericHitlApprovalToolUI {...props} />;
}
return <DefaultToolFallbackInner {...props} />;
};

View file

@ -82,11 +82,12 @@ export const DocumentNode = React.memo(function DocumentNode({
onContextMenuOpenChange,
}: DocumentNodeProps) {
const statusState = doc.status?.state ?? "ready";
const isSelectable = statusState !== "pending" && statusState !== "processing";
const isFailed = statusState === "failed";
const isProcessing = statusState === "pending" || statusState === "processing";
const isUnavailable = isProcessing || isFailed;
const isSelectable = !isUnavailable;
const isEditable =
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) &&
statusState !== "pending" &&
statusState !== "processing";
EDITABLE_DOCUMENT_TYPES.has(doc.document_type) && !isUnavailable;
const handleCheckChange = useCallback(() => {
if (isSelectable) {
@ -103,7 +104,6 @@ export const DocumentNode = React.memo(function DocumentNode({
[doc.id]
);
const isProcessing = statusState === "pending" || statusState === "processing";
const [dropdownOpen, setDropdownOpen] = useState(false);
const [exporting, setExporting] = useState<string | null>(null);
const [titleTooltipOpen, setTitleTooltipOpen] = useState(false);
@ -261,38 +261,38 @@ export const DocumentNode = React.memo(function DocumentNode({
className="w-40"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
<Eye className="mr-2 h-4 w-4" />
Open
<DropdownMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" />
Open
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
{isEditable && (
<DropdownMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
)}
<DropdownMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</DropdownMenuItem>
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
{onExport && (
<DropdownMenuSub>
<DropdownMenuSubTrigger disabled={isProcessing}>
<Download className="mr-2 h-4 w-4" />
Export
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-[180px]">
<ExportDropdownItems onExport={handleExport} exporting={exporting} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<DropdownMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</DropdownMenuItem>
)}
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
)}
<DropdownMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
@ -304,38 +304,38 @@ export const DocumentNode = React.memo(function DocumentNode({
{contextMenuOpen && (
<ContextMenuContent className="w-40" onClick={(e) => e.stopPropagation()}>
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isProcessing}>
<Eye className="mr-2 h-4 w-4" />
Open
<ContextMenuItem onClick={() => onPreview(doc)} disabled={isUnavailable}>
<Eye className="mr-2 h-4 w-4" />
Open
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
{isEditable && (
<ContextMenuItem onClick={() => onEdit(doc)}>
<PenLine className="mr-2 h-4 w-4" />
Edit
</ContextMenuItem>
)}
<ContextMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
)}
<ContextMenuItem onClick={() => onMove(doc)}>
<Move className="mr-2 h-4 w-4" />
Move to...
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={isUnavailable}>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isUnavailable} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>
{onExport && (
<ContextMenuSub>
<ContextMenuSubTrigger disabled={isProcessing}>
<Download className="mr-2 h-4 w-4" />
Export
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
<ExportContextItems onExport={handleExport} exporting={exporting} />
</ContextMenuSubContent>
</ContextMenuSub>
)}
{onVersionHistory && isVersionableType(doc.document_type) && (
<ContextMenuItem disabled={isProcessing} onClick={() => onVersionHistory(doc)}>
<History className="mr-2 h-4 w-4" />
Versions
</ContextMenuItem>
)}
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
)}
<ContextMenuItem disabled={isProcessing} onClick={() => onDelete(doc)}>
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>

View file

@ -1,6 +1,6 @@
"use client";
import { Download, FolderPlus, ListFilter, Loader2, Search, Upload, X } from "lucide-react";
import { FolderPlus, ListFilter, Search, Upload, X } from "lucide-react";
import { useTranslations } from "next-intl";
import React, { useCallback, useMemo, useRef, useState } from "react";
import { useDocumentUploadDialog } from "@/components/assistant-ui/document-upload-popup";
@ -20,8 +20,6 @@ export function DocumentsFilters({
onToggleType,
activeTypes,
onCreateFolder,
onExportKB,
isExporting,
}: {
typeCounts: Partial<Record<DocumentTypeEnum, number>>;
onSearch: (v: string) => void;
@ -29,8 +27,6 @@ export function DocumentsFilters({
onToggleType: (type: DocumentTypeEnum, checked: boolean) => void;
activeTypes: DocumentTypeEnum[];
onCreateFolder?: () => void;
onExportKB?: () => void;
isExporting?: boolean;
}) {
const t = useTranslations("documents");
const id = React.useId();
@ -85,33 +81,8 @@ export function DocumentsFilters({
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>New folder</TooltipContent>
</Tooltip>
)}
{onExportKB && (
<Tooltip>
<TooltipTrigger asChild>
<ToggleGroupItem
value="export"
disabled={isExporting}
className="h-9 w-9 shrink-0 border-sidebar-border text-sidebar-foreground/60 hover:text-sidebar-foreground hover:border-sidebar-border bg-sidebar"
onClick={(e) => {
e.preventDefault();
onExportKB();
}}
>
{isExporting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
</ToggleGroupItem>
</TooltipTrigger>
<TooltipContent>
{isExporting ? "Exporting…" : "Export knowledge base"}
</TooltipContent>
</Tooltip>
)}
</Tooltip>
)}
<Popover>
<Tooltip>

View file

@ -532,16 +532,14 @@ export function LayoutDataProvider({ searchSpaceId, children }: LayoutDataProvid
const isOutOfSync = currentThreadState.id !== null && !params?.chat_id;
if (isOutOfSync) {
// First sync Next.js router by navigating to the current chat's actual URL
// This updates the router's internal state to match the browser URL
resetCurrentThread();
router.replace(`/dashboard/${searchSpaceId}/new-chat/${currentThreadState.id}`);
// Allow router to sync, then navigate to fresh new-chat
setTimeout(() => {
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}, 0);
// Immediately set the browser URL so the page remounts with a clean /new-chat path
window.history.replaceState(null, "", `/dashboard/${searchSpaceId}/new-chat`);
// Force-remount the page component to reset all React state synchronously
setChatResetKey((k) => k + 1);
// Sync Next.js router internals so useParams/usePathname stay correct going forward
router.replace(`/dashboard/${searchSpaceId}/new-chat`);
} else {
// Normal navigation - router is in sync
router.push(`/dashboard/${searchSpaceId}/new-chat`);
}
}, [router, searchSpaceId, currentThreadState.id, params?.chat_id, resetCurrentThread]);

View file

@ -406,22 +406,13 @@ export function DocumentsSidebar({
setFolderPickerOpen(true);
}, []);
const [isExportingKB, setIsExportingKB] = useState(false);
const [, setIsExportingKB] = useState(false);
const [exportWarningOpen, setExportWarningOpen] = useState(false);
const [exportWarningContext, setExportWarningContext] = useState<{
type: "kb" | "folder";
folder?: FolderDisplay;
folder: FolderDisplay;
pendingCount: number;
} | null>(null);
const pendingDocuments = useMemo(
() =>
treeDocuments.filter(
(d) => d.status?.state === "pending" || d.status?.state === "processing"
),
[treeDocuments]
);
const doExport = useCallback(async (url: string, downloadName: string) => {
const response = await authenticatedFetch(url, { method: "GET" });
if (!response.ok) {
@ -440,68 +431,28 @@ export function DocumentsSidebar({
URL.revokeObjectURL(blobUrl);
}, []);
const handleExportKB = useCallback(async () => {
if (isExportingKB) return;
if (pendingDocuments.length > 0) {
setExportWarningContext({ type: "kb", pendingCount: pendingDocuments.length });
setExportWarningOpen(true);
return;
}
setIsExportingKB(true);
try {
await doExport(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
"knowledge-base.zip"
);
toast.success("Knowledge base exported");
} catch (err) {
console.error("KB export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
}
}, [searchSpaceId, isExportingKB, pendingDocuments.length, doExport]);
const handleExportWarningConfirm = useCallback(async () => {
setExportWarningOpen(false);
const ctx = exportWarningContext;
if (!ctx) return;
if (!ctx?.folder) return;
if (ctx.type === "kb") {
setIsExportingKB(true);
try {
await doExport(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
"knowledge-base.zip"
);
toast.success("Knowledge base exported");
} catch (err) {
console.error("KB export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
}
} else if (ctx.type === "folder" && ctx.folder) {
setIsExportingKB(true);
try {
const safeName =
ctx.folder.name
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "folder";
await doExport(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
`${safeName}.zip`
);
toast.success(`Folder "${ctx.folder.name}" exported`);
} catch (err) {
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
}
setIsExportingKB(true);
try {
const safeName =
ctx.folder.name
.replace(/[^a-zA-Z0-9 _-]/g, "_")
.trim()
.slice(0, 80) || "folder";
await doExport(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export?folder_id=${ctx.folder.id}`,
`${safeName}.zip`
);
toast.success(`Folder "${ctx.folder.name}" exported`);
} catch (err) {
console.error("Folder export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExportingKB(false);
}
setExportWarningContext(null);
}, [exportWarningContext, searchSpaceId, doExport]);
@ -530,7 +481,6 @@ export function DocumentsSidebar({
const folderPendingCount = getPendingCountInSubtree(folder.id);
if (folderPendingCount > 0) {
setExportWarningContext({
type: "folder",
folder,
pendingCount: folderPendingCount,
});
@ -677,9 +627,10 @@ export function DocumentsSidebar({
function collectSubtreeDocs(parentId: number): DocumentNodeDoc[] {
const directDocs = (treeDocuments ?? []).filter(
(d) =>
d.folderId === parentId &&
d.status?.state !== "pending" &&
d.status?.state !== "processing"
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));
@ -954,8 +905,6 @@ export function DocumentsSidebar({
onToggleType={onToggleType}
activeTypes={activeTypes}
onCreateFolder={() => handleCreateFolder(null)}
onExportKB={handleExportKB}
isExporting={isExportingKB}
/>
</div>

View file

@ -29,8 +29,6 @@ interface DocumentMentionPickerProps {
onDone: () => void;
initialSelectedDocuments?: Pick<Document, "id" | "title" | "document_type">[];
externalSearch?: string;
/** Positioning styles for the container */
containerStyle?: React.CSSProperties;
}
const PAGE_SIZE = 20;
@ -75,7 +73,6 @@ export const DocumentMentionPicker = forwardRef<
onDone,
initialSelectedDocuments = [],
externalSearch = "",
containerStyle,
},
ref
) {
@ -394,19 +391,9 @@ export const DocumentMentionPicker = forwardRef<
[selectableDocuments, highlightedIndex, handleSelectDocument, onDone]
);
// Hide popup when there are no documents to display (regardless of fetch state)
// Search continues in background; popup reappears when results arrive
if (!actualLoading && actualDocuments.length === 0) {
return null;
}
return (
<div
className="fixed shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
style={{
zIndex: 9999,
...containerStyle,
}}
className="shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none"
onKeyDown={handleKeyDown}
role="listbox"
tabIndex={-1}
@ -547,7 +534,11 @@ export const DocumentMentionPicker = forwardRef<
</div>
)}
</div>
) : null}
) : (
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-muted-foreground">No matching documents</p>
</div>
)}
</div>
</div>
);

View file

@ -15,7 +15,7 @@ import {
import { promptsAtom } from "@/atoms/prompts/prompts-query.atoms";
import { userSettingsDialogAtom } from "@/atoms/settings/settings-dialog.atoms";
import { Spinner } from "@/components/ui/spinner";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
export interface PromptPickerRef {
@ -28,11 +28,10 @@ interface PromptPickerProps {
onSelect: (action: { name: string; prompt: string; mode: "transform" | "explore" }) => void;
onDone: () => void;
externalSearch?: string;
containerStyle?: React.CSSProperties;
}
export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(function PromptPicker(
{ onSelect, onDone, externalSearch = "", containerStyle },
{ onSelect, onDone, externalSearch = "" },
ref
) {
const setUserSettingsDialog = useSetAtom(userSettingsDialogAtom);
@ -60,13 +59,21 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
}
}
const createPromptIndex = filtered.length;
const totalItems = filtered.length + 1;
const handleSelect = useCallback(
(index: number) => {
if (index === createPromptIndex) {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
return;
}
const action = filtered[index];
if (!action) return;
onSelect({ name: action.name, prompt: action.prompt, mode: action.mode });
},
[filtered, onSelect]
[filtered, onSelect, createPromptIndex, onDone, setUserSettingsDialog]
);
useEffect(() => {
@ -93,69 +100,98 @@ export const PromptPicker = forwardRef<PromptPickerRef, PromptPickerProps>(funct
() => ({
selectHighlighted: () => handleSelect(highlightedIndex),
moveUp: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : filtered.length - 1));
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : totalItems - 1));
},
moveDown: () => {
if (filtered.length === 0) return;
shouldScrollRef.current = true;
setHighlightedIndex((prev) => (prev < filtered.length - 1 ? prev + 1 : 0));
setHighlightedIndex((prev) => (prev < totalItems - 1 ? prev + 1 : 0));
},
}),
[filtered.length, highlightedIndex, handleSelect]
[totalItems, highlightedIndex, handleSelect]
);
return (
<div
className="w-64 rounded-lg border bg-popover shadow-lg overflow-hidden"
style={containerStyle}
>
<div ref={scrollContainerRef} className="max-h-48 overflow-y-auto py-1">
<div className="shadow-2xl rounded-lg border border-border dark:border-white/5 overflow-hidden bg-popover dark:bg-neutral-900 flex flex-col w-[280px] sm:w-[320px] select-none">
<div ref={scrollContainerRef} className="max-h-[180px] sm:max-h-[280px] overflow-y-auto">
{isLoading ? (
<div className="flex items-center justify-center py-3">
<Spinner className="size-4" />
<div className="py-1 px-2">
<div className="px-3 py-2">
<Skeleton className="h-[16px] w-24" />
</div>
{["a", "b", "c", "d", "e"].map((id, i) => (
<div
key={id}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left rounded-md",
i >= 3 && "hidden sm:flex"
)}
>
<span className="shrink-0">
<Skeleton className="h-4 w-4" />
</span>
<span className="flex-1 text-sm">
<Skeleton className="h-[20px]" style={{ width: `${60 + ((i * 7) % 30)}%` }} />
</span>
</div>
))}
</div>
) : isError ? (
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-destructive">Failed to load prompts</p>
</div>
) : filtered.length === 0 ? (
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
<div className="py-1 px-2">
<p className="px-3 py-2 text-xs text-muted-foreground">No matching prompts</p>
</div>
) : (
filtered.map((action, index) => (
<div className="py-1 px-2">
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
Saved Prompts
</div>
{filtered.map((action, index) => (
<button
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer",
index === highlightedIndex && "bg-accent"
)}
>
<span className="shrink-0 text-muted-foreground">
<Zap className="size-4" />
</span>
<span className="flex-1 text-sm truncate">{action.name}</span>
</button>
))}
<div className="mx-2 my-1 border-t border-border dark:border-white/5" />
<button
key={action.id}
ref={(el) => {
if (el) itemRefs.current.set(index, el);
else itemRefs.current.delete(index);
if (el) itemRefs.current.set(createPromptIndex, el);
else itemRefs.current.delete(createPromptIndex);
}}
type="button"
onClick={() => handleSelect(index)}
onMouseEnter={() => setHighlightedIndex(index)}
onClick={() => handleSelect(createPromptIndex)}
onMouseEnter={() => setHighlightedIndex(createPromptIndex)}
className={cn(
"flex w-full items-center gap-2 px-3 py-1.5 text-sm cursor-pointer",
index === highlightedIndex ? "bg-accent" : "hover:bg-accent/50"
"w-full flex items-center gap-2 px-3 py-2 text-left text-sm transition-colors rounded-md cursor-pointer text-muted-foreground",
highlightedIndex === createPromptIndex ? "bg-accent text-foreground" : "hover:text-foreground hover:bg-accent/50"
)}
>
<span className="text-muted-foreground">
<Zap className="size-3.5" />
<span className="shrink-0">
<Plus className="size-4" />
</span>
<span className="truncate">{action.name}</span>
<span>Create prompt</span>
</button>
))
</div>
)}
<div className="my-1 h-px bg-border mx-2" />
<button
type="button"
onClick={() => {
onDone();
setUserSettingsDialog({ open: true, initialTab: "prompts" });
}}
className="flex w-full items-center gap-2 px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 cursor-pointer"
>
<Plus className="size-3.5" />
<span>Create prompt</span>
</button>
</div>
</div>
);

View file

@ -10,7 +10,6 @@ import {
MessageSquareQuote,
RefreshCw,
Trash2,
Wand2,
} from "lucide-react";
import { useMemo, useState } from "react";
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
@ -43,7 +42,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { getProviderIcon } from "@/lib/provider-icons";
import { cn } from "@/lib/utils";
interface ModelConfigManagerProps {
interface AgentModelManagerProps {
searchSpaceId: number;
}
@ -55,7 +54,7 @@ function getInitials(name: string): string {
return name.slice(0, 2).toUpperCase();
}
export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
const isDesktop = useMediaQuery("(min-width: 768px)");
// Mutations
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
@ -208,28 +207,26 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3">
{/* Header */}
<div className="flex items-start justify-between gap-2">
{/* Header: Icon + Name */}
<div className="flex items-start gap-2.5">
<Skeleton className="size-4 rounded-full shrink-0 mt-0.5" />
<div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" />
</div>
</div>
{/* Provider + Model */}
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
{/* Feature badges */}
<div className="flex items-center gap-1.5">
<Skeleton className="h-5 w-20 rounded-full" />
<Skeleton className="h-5 w-16 rounded-full" />
</div>
{/* Footer */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
<div className="flex items-center pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20 flex-1" />
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
<div className="flex-1 flex items-center justify-end gap-1.5">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</CardContent>
</Card>
@ -262,20 +259,25 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
{/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
@ -284,7 +286,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
</Button>
@ -301,7 +303,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
@ -314,20 +316,12 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
)}
</div>
{/* Provider + Model */}
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, { className: "size-3.5 shrink-0" })}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
{/* Feature badges */}
<div className="flex items-center gap-1.5 flex-wrap">
{config.citations_enabled && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0.5 border-emerald-500/30 text-emerald-700 dark:text-emerald-300 bg-emerald-500/5"
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
<MessageSquareQuote className="h-2.5 w-2.5 mr-1" />
Citations
@ -336,8 +330,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{!config.use_default_system_instructions &&
config.system_instructions && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0.5 border-blue-500/30 text-blue-700 dark:text-blue-300 bg-blue-500/5"
variant="secondary"
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
>
<FileText className="h-2.5 w-2.5 mr-1" />
Custom
@ -346,8 +340,8 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</div>
{/* Footer: Date + Creator */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60">
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
@ -356,11 +350,11 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
@ -369,7 +363,7 @@ export function ModelConfigManager({ searchSpaceId }: ModelConfigManagerProps) {
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
<span className="text-[11px] text-muted-foreground/60 truncate">
{member.name}
</span>
</div>

View file

@ -2,18 +2,18 @@
import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import { Info } from "lucide-react";
import { FolderArchive, Info } from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service";
import { authenticatedFetch } from "@/lib/auth-utils";
import { cacheKeys } from "@/lib/query-client/cache-keys";
import { Spinner } from "../ui/spinner";
@ -40,6 +40,37 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [saving, setSaving] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const handleExportKB = useCallback(async () => {
if (isExporting) return;
setIsExporting(true);
try {
const response = await authenticatedFetch(
`${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/export`,
{ method: "GET" }
);
if (!response.ok) {
const errorData = await response.json().catch(() => ({ detail: "Export failed" }));
throw new Error(errorData.detail || "Export failed");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "knowledge-base.zip";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Knowledge base exported");
} catch (err) {
console.error("KB export failed:", err);
toast.error(err instanceof Error ? err.message : "Export failed");
} finally {
setIsExporting(false);
}
}, [searchSpaceId, isExporting]);
// Initialize state from fetched search space
useEffect(() => {
@ -83,16 +114,10 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
if (loading) {
return (
<div className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<Skeleton className="h-5 md:h-6 w-36 md:w-48" />
<Skeleton className="h-3 md:h-4 w-full max-w-md mt-2" />
</CardHeader>
<CardContent className="space-y-3 md:space-y-4 px-3 md:px-6 pb-3 md:pb-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</CardContent>
</Card>
<div className="flex flex-col gap-6">
<Skeleton className="h-10 md:h-12 w-full" />
<Skeleton className="h-10 md:h-12 w-full" />
</div>
</div>
);
}
@ -113,61 +138,45 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
<Alert className="bg-muted/50 py-3 md:py-4">
<Info className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
<AlertDescription className="text-xs md:text-sm">
Update your search space name and description. These details help identify and organize
your workspace.
Update your search space name and description.
</AlertDescription>
</Alert>
{/* Search Space Details Card */}
<form onSubmit={onSubmit} className="space-y-4 md:space-y-6">
<Card>
<CardHeader className="px-3 md:px-6 pt-3 md:pt-6 pb-2 md:pb-3">
<CardTitle className="text-base md:text-lg">Search Space Details</CardTitle>
<CardDescription className="text-xs md:text-sm">
Manage the basic information for this search space.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 md:space-y-5 px-3 md:px-6 pb-3 md:pb-6">
<div className="space-y-1.5 md:space-y-2">
<Label htmlFor="search-space-name" className="text-sm md:text-base font-medium">
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
<div className="flex flex-col gap-6">
<div className="space-y-2">
<Label htmlFor="search-space-name">
{t("general_name_label")}
</Label>
<Input
id="search-space-name"
placeholder={t("general_name_placeholder")}
value={name}
onChange={(e) => setName(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("general_name_description")}
</p>
</div>
<div className="space-y-1.5 md:space-y-2">
<Label
htmlFor="search-space-description"
className="text-sm md:text-base font-medium"
>
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label>
<Input
id="search-space-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
className="text-sm md:text-base h-9 md:h-10"
/>
<p className="text-[10px] md:text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</CardContent>
</Card>
<div className="space-y-2">
<Label htmlFor="search-space-description">
{t("general_description_label")}{" "}
<span className="text-muted-foreground font-normal">({tCommon("optional")})</span>
</Label>
<Input
id="search-space-description"
placeholder={t("general_description_placeholder")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t("general_description_description")}
</p>
</div>
</div>
{/* Action Buttons */}
<div className="flex justify-end pt-3 md:pt-4">
<div className="flex justify-end">
<Button
type="submit"
variant="outline"
@ -179,6 +188,29 @@ export function GeneralSettingsManager({ searchSpaceId }: GeneralSettingsManager
</Button>
</div>
</form>
<div className="border-t pt-6 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<Label>Export knowledge base</Label>
<p className="text-xs text-muted-foreground">
Download all documents in this search space as a ZIP of markdown files.
</p>
</div>
<Button
type="button"
variant="secondary"
size="sm"
disabled={isExporting}
onClick={handleExportKB}
className="relative w-fit shrink-0"
>
<span className={isExporting ? "opacity-0" : ""}>
<FolderArchive className="h-3 w-3 opacity-60" />
</span>
<span className={isExporting ? "opacity-0" : ""}>Export</span>
{isExporting && <Spinner size="sm" className="absolute" />}
</Button>
</div>
</div>
);
}

View file

@ -1,7 +1,7 @@
"use client";
import { useAtomValue } from "jotai";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2, Wand2 } from "lucide-react";
import { AlertCircle, Dot, Edit3, Info, RefreshCw, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
import {
@ -209,20 +209,20 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<Skeleton className="size-4 rounded-full shrink-0" />
<div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
<div className="flex items-center pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20 flex-1" />
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
<div className="flex-1 flex items-center justify-end gap-1.5">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</CardContent>
</Card>
@ -255,20 +255,25 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
{/* Header: Name + Actions */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
{/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
@ -277,7 +282,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
</Button>
@ -294,7 +299,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
@ -307,17 +312,9 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
)}
</div>
{/* Provider + Model */}
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, { className: "size-3.5 shrink-0" })}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
{/* Footer: Date + Creator */}
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60">
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
@ -326,11 +323,11 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
@ -339,7 +336,7 @@ export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
<span className="text-[11px] text-muted-foreground/60 truncate">
{member.name}
</span>
</div>

View file

@ -6,7 +6,7 @@ import {
Bot,
CircleCheck,
CircleDashed,
Eye,
ScanEye,
FileText,
ImageIcon,
RefreshCw,
@ -74,7 +74,7 @@ const ROLE_DESCRIPTIONS = {
configType: "image" as const,
},
vision: {
icon: Eye,
icon: ScanEye,
title: "Vision LLM",
description: "Vision-capable model for screenshot analysis and context extraction",
color: "text-muted-foreground",

View file

@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query";
import { useAtomValue } from "jotai";
import {
Bot,
ChevronDown,
Edit2,
FileText,
Globe,
@ -47,7 +48,6 @@ import {
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
DropdownMenu,
@ -58,7 +58,6 @@ import {
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Spinner } from "@/components/ui/spinner";
import type { PermissionInfo } from "@/contracts/types/permissions.types";
import type {
@ -319,100 +318,6 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) {
);
}
// ============ Role Permissions Display ============
function RolePermissionsDialog({
permissions,
roleName,
children,
}: {
permissions: string[];
roleName: string;
children: React.ReactNode;
}) {
const isFullAccess = permissions.includes("*");
const grouped: Record<string, string[]> = {};
if (!isFullAccess) {
for (const perm of permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
}
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
const categoryCount = sortedCategories.length;
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="w-[92vw] max-w-md p-0 gap-0">
<DialogHeader className="p-4 md:p-5">
<DialogTitle className="text-base">{roleName} Permissions</DialogTitle>
<DialogDescription className="text-xs">
{isFullAccess
? "This role has unrestricted access to all resources"
: `${permissions.length} permissions across ${categoryCount} categories`}
</DialogDescription>
</DialogHeader>
{isFullAccess ? (
<div className="flex items-center gap-3 px-4 md:px-5 py-6">
<div className="h-9 w-9 rounded-lg bg-muted/60 flex items-center justify-center shrink-0">
<Shield className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<p className="text-sm font-medium">Full access</p>
<p className="text-xs text-muted-foreground">
All permissions granted across every category
</p>
</div>
</div>
) : (
<ScrollArea className="max-h-[55vh]">
<div className="divide-y divide-border/50">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
};
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 px-4 md:px-5 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{config.label}</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</DialogContent>
</Dialog>
);
}
function PermissionsBadge({ permissions }: { permissions: string[] }) {
if (permissions.includes("*")) {
return (
@ -463,6 +368,7 @@ function RolesContent({
}) {
const [showCreateRole, setShowCreateRole] = useState(false);
const [editingRoleId, setEditingRoleId] = useState<number | null>(null);
const [expandedRoleId, setExpandedRoleId] = useState<number | null>(null);
if (loading) {
return (
@ -508,91 +414,170 @@ function RolesContent({
)}
<div className="space-y-3">
{roles.map((role) => (
<div key={role.id}>
<div className="w-full text-left relative flex items-center gap-4 rounded-lg border border-border/60 p-4 transition-colors hover:bg-muted/30">
<div className="flex-1 min-w-0">
<RolePermissionsDialog permissions={role.permissions} roleName={role.name}>
<button type="button" className="w-full text-left cursor-pointer">
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{role.name}</span>
{role.is_system_role && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
System
</span>
)}
{role.is_default && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Default
</span>
)}
</div>
{role.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{role.description}
</p>
{roles.map((role) => {
const isExpanded = expandedRoleId === role.id;
const isFullAccess = role.permissions.includes("*");
const grouped: Record<string, string[]> = {};
if (!isFullAccess) {
for (const perm of role.permissions) {
const [category, action] = perm.split(":");
if (!grouped[category]) grouped[category] = [];
grouped[category].push(action);
}
}
const sortedCategories = Object.keys(grouped).sort((a, b) => {
const orderA = CATEGORY_CONFIG[a]?.order ?? 99;
const orderB = CATEGORY_CONFIG[b]?.order ?? 99;
return orderA - orderB;
});
return (
<div key={role.id} className="rounded-lg border border-border/60 overflow-hidden">
<div className="flex items-center gap-4 p-4 transition-colors hover:bg-muted/30">
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<div className="flex items-center gap-2">
<span className="font-medium text-sm">{role.name}</span>
{role.is_system_role && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
System
</span>
)}
</button>
</RolePermissionsDialog>
{role.is_default && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Default
</span>
)}
</div>
{role.description && (
<p className="text-xs text-muted-foreground mt-0.5 truncate">
{role.description}
</p>
)}
</button>
<div className="shrink-0">
<PermissionsBadge permissions={role.permissions} />
</div>
{!role.is_system_role && (
<div className="shrink-0" role="none">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
{canUpdate && (
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Edit Role
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trash2 className="h-4 w-4 mr-2" />
Delete Role
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete role?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the &quot;{role.name}&quot; role.
Members with this role will lose their permissions.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDeleteRole(role.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
)}
<button
type="button"
className="shrink-0 p-1 cursor-pointer"
onClick={() => setExpandedRoleId(isExpanded ? null : role.id)}
>
<ChevronDown
className={cn(
"h-4 w-4 text-muted-foreground transition-transform duration-200",
isExpanded && "rotate-180"
)}
/>
</button>
</div>
<div className="shrink-0">
<PermissionsBadge permissions={role.permissions} />
</div>
{!role.is_system_role && (
<div className="shrink-0" role="none">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" onCloseAutoFocus={(e) => e.preventDefault()}>
{canUpdate && (
<DropdownMenuItem onClick={() => setEditingRoleId(role.id)}>
<Edit2 className="h-4 w-4 mr-2" />
Edit Role
</DropdownMenuItem>
)}
{canDelete && (
<>
<DropdownMenuSeparator />
<AlertDialog>
<AlertDialogTrigger asChild>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Trash2 className="h-4 w-4 mr-2" />
Delete Role
</DropdownMenuItem>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete role?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently delete the &quot;{role.name}&quot; role.
Members with this role will lose their permissions.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDeleteRole(role.id)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
{isExpanded && (
<div className="border-t border-border/40 px-4 py-3">
{isFullAccess ? (
<div className="flex items-center gap-3 py-2">
<Shield className="h-4 w-4 text-muted-foreground shrink-0" />
<p className="text-sm text-muted-foreground">
Full access all permissions granted across every category
</p>
</div>
) : (
<div className="divide-y divide-border/30">
{sortedCategories.map((category) => {
const actions = grouped[category];
const config = CATEGORY_CONFIG[category] || {
label: category,
icon: FileText,
};
const IconComponent = config.icon;
return (
<div
key={category}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex items-center gap-2 shrink-0">
<IconComponent className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{config.label}
</span>
</div>
<div className="flex flex-wrap justify-end gap-1">
{actions.map((action) => (
<span
key={action}
className="px-1.5 py-0.5 rounded bg-muted text-muted-foreground text-[11px] font-medium"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{ACTION_LABELS[action] || action.replace(/_/g, " ")}
</span>
))}
</div>
</div>
);
})}
</div>
)}
</div>
)}
</div>
</div>
))}
);
})}
</div>
</div>
);
@ -676,46 +661,51 @@ function PermissionsEditor({
return (
<div key={category} className="rounded-lg border border-border/60 overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-3 py-2.5 cursor-pointer hover:bg-muted/40 transition-colors"
onClick={() => toggleCategoryExpanded(category)}
>
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-between px-3 py-2.5 hover:bg-muted/40 transition-colors">
<button
type="button"
className="flex-1 flex items-center gap-2.5 cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<IconComponent className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="font-medium text-sm">{config.label}</span>
<span className="text-[11px] text-muted-foreground tabular-nums">
{stats.selected}/{stats.total}
</span>
</div>
</button>
<div className="flex items-center gap-2">
<Checkbox
checked={stats.allSelected}
onCheckedChange={() => onToggleCategory(category)}
onClick={(e) => e.stopPropagation()}
aria-label={`Select all ${config.label} permissions`}
/>
<div
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
<button
type="button"
className="cursor-pointer"
onClick={() => toggleCategoryExpanded(category)}
>
<svg
className="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
<div
className={cn("transition-transform duration-200", isExpanded && "rotate-180")}
>
<title>Toggle</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
<svg
className="h-4 w-4 text-muted-foreground"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
aria-hidden="true"
>
<title>Toggle</title>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</button>
</div>
</button>
</div>
{isExpanded && (
<div className="border-t border-border/60">
@ -726,28 +716,29 @@ function PermissionsEditor({
const isSelected = selectedPermissions.includes(perm.value);
return (
<button
<div
key={perm.value}
type="button"
className={cn(
"w-full flex items-center justify-between gap-3 px-2.5 py-2 rounded-md cursor-pointer transition-colors",
"flex items-center justify-between gap-3 px-2.5 py-2 rounded-md transition-colors",
isSelected ? "bg-muted/60 hover:bg-muted/80" : "hover:bg-muted/40"
)}
onClick={() => onTogglePermission(perm.value)}
>
<div className="flex-1 min-w-0 text-left">
<button
type="button"
className="flex-1 min-w-0 text-left cursor-pointer"
onClick={() => onTogglePermission(perm.value)}
>
<span className="text-sm font-medium">{actionLabel}</span>
<p className="text-xs text-muted-foreground truncate">
{perm.description}
</p>
</div>
</button>
<Checkbox
checked={isSelected}
onCheckedChange={() => onTogglePermission(perm.value)}
onClick={(e) => e.stopPropagation()}
className="shrink-0"
/>
</button>
</div>
);
})}
</div>

View file

@ -7,7 +7,7 @@ import {
Brain,
CircleUser,
Earth,
Eye,
ScanEye,
ImageIcon,
ListChecks,
UserKey,
@ -25,10 +25,10 @@ const GeneralSettingsManager = dynamic(
})),
{ ssr: false }
);
const ModelConfigManager = dynamic(
const AgentModelManager = dynamic(
() =>
import("@/components/settings/model-config-manager").then((m) => ({
default: m.ModelConfigManager,
import("@/components/settings/agent-model-manager").then((m) => ({
default: m.AgentModelManager,
})),
{ ssr: false }
);
@ -88,7 +88,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
const navItems = [
{ value: "general", label: t("nav_general"), icon: <CircleUser className="h-4 w-4" /> },
{ value: "roles", label: t("nav_role_assignments"), icon: <ListChecks className="h-4 w-4" /> },
{ value: "models", label: t("nav_agent_configs"), icon: <Bot className="h-4 w-4" /> },
{ value: "models", label: t("nav_agent_models"), icon: <Bot className="h-4 w-4" /> },
{
value: "image-models",
label: t("nav_image_models"),
@ -97,7 +97,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
{
value: "vision-models",
label: t("nav_vision_models"),
icon: <Eye className="h-4 w-4" />,
icon: <ScanEye className="h-4 w-4" />,
},
{ value: "team-roles", label: t("nav_team_roles"), icon: <UserKey className="h-4 w-4" /> },
{
@ -115,7 +115,7 @@ export function SearchSpaceSettingsDialog({ searchSpaceId }: SearchSpaceSettings
const content: Record<string, React.ReactNode> = {
general: <GeneralSettingsManager searchSpaceId={searchSpaceId} />,
models: <ModelConfigManager searchSpaceId={searchSpaceId} />,
models: <AgentModelManager searchSpaceId={searchSpaceId} />,
roles: <LLMRoleManager searchSpaceId={searchSpaceId} />,
"image-models": <ImageModelManager searchSpaceId={searchSpaceId} />,
"vision-models": <VisionModelManager searchSpaceId={searchSpaceId} />,

View file

@ -208,20 +208,20 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
<Card key={key} className="border-border/60">
<CardContent className="p-4 flex flex-col gap-3">
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2.5">
<Skeleton className="size-4 rounded-full shrink-0" />
<div className="space-y-1.5 flex-1 min-w-0">
<Skeleton className="h-4 w-28 md:w-32" />
<Skeleton className="h-3 w-40 md:w-48" />
</div>
</div>
<div className="flex items-center gap-2">
<Skeleton className="h-5 w-16 rounded-full" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
<div className="flex items-center pt-2 border-t border-border/40">
<Skeleton className="h-3 w-20 flex-1" />
<Skeleton className="h-3 w-3 rounded-full shrink-0 mx-1" />
<div className="flex-1 flex items-center justify-end gap-1.5">
<Skeleton className="h-4 w-4 rounded-full" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</CardContent>
</Card>
@ -253,19 +253,25 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
<div key={config.id}>
<Card className="group relative overflow-hidden transition-all duration-200 border-border/60 hover:shadow-md h-full">
<CardContent className="p-4 flex flex-col gap-3 h-full">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
{/* Header: Icon + Name + Actions */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<div className="shrink-0">
{getProviderIcon(config.provider, { className: "size-4" })}
</div>
<div className="min-w-0 flex-1">
<h4 className="text-sm font-semibold tracking-tight truncate">
{config.name}
</h4>
{config.description && (
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
{config.description}
</p>
)}
</div>
</div>
{(canUpdate || canDelete) && (
<div className="flex items-center gap-0.5 shrink-0 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity duration-150">
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
{canUpdate && (
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
@ -274,7 +280,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
variant="ghost"
size="icon"
onClick={() => openEditDialog(config)}
className="h-7 w-7 text-muted-foreground hover:text-foreground"
className="h-6 w-6 text-muted-foreground hover:text-foreground"
>
<Edit3 className="h-3 w-3" />
</Button>
@ -291,7 +297,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
variant="ghost"
size="icon"
onClick={() => setConfigToDelete(config)}
className="h-7 w-7 text-muted-foreground hover:text-destructive"
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3 w-3" />
</Button>
@ -304,17 +310,9 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{getProviderIcon(config.provider, {
className: "size-3.5 shrink-0",
})}
<code className="text-[11px] font-mono text-muted-foreground bg-muted/60 px-2 py-0.5 rounded-md truncate max-w-[160px]">
{config.model_name}
</code>
</div>
<div className="flex items-center gap-2 pt-2 border-t border-border/40 mt-auto">
<span className="text-[11px] text-muted-foreground/60">
{/* Footer: Date + Creator */}
<div className="flex items-center pt-2 border-t border-border/40 mt-auto">
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
{new Date(config.created_at).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
@ -323,11 +321,11 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
</span>
{member && (
<>
<Dot className="h-4 w-4 text-muted-foreground/30" />
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
<TooltipProvider>
<Tooltip open={isDesktop ? undefined : false}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1.5 cursor-default">
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
<Avatar className="size-4.5 shrink-0">
{member.avatarUrl && (
<AvatarImage src={member.avatarUrl} alt={member.name} />
@ -336,7 +334,7 @@ export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
{getInitials(member.name)}
</AvatarFallback>
</Avatar>
<span className="text-[11px] text-muted-foreground/60 truncate max-w-[120px]">
<span className="text-[11px] text-muted-foreground/60 truncate">
{member.name}
</span>
</div>

View file

@ -15,6 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface ConfluenceAccount {
@ -30,24 +32,10 @@ interface ConfluenceSpace {
name: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
accounts?: ConfluenceAccount[];
spaces?: ConfluenceSpace[];
error?: string;
};
type CreateConfluencePageInterruptContext = {
accounts?: ConfluenceAccount[];
spaces?: ConfluenceSpace[];
error?: string;
}
interface SuccessResult {
@ -76,21 +64,12 @@ interface InsufficientPermissionsResult {
}
type CreateConfluencePageResult =
| InterruptResult
| InterruptResult<CreateConfluencePageInterruptContext>
| SuccessResult
| ErrorResult
| AuthErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -124,12 +103,8 @@ function ApprovalCard({
onDecision,
}: {
args: { title: string; content?: string; space_id?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<CreateConfluencePageInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -464,18 +439,16 @@ export const CreateConfluencePageToolUI = ({
{ title: string; content?: string; space_id?: string },
CreateConfluencePageResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<CreateConfluencePageInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -6,38 +6,26 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
type DeleteConfluencePageInterruptContext = {
account?: {
id: number;
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: {
id: number;
name: string;
base_url: string;
auth_expired?: boolean;
};
page?: {
page_id: string;
page_title: string;
space_id: string;
connector_id?: number;
document_id?: number;
indexed_at?: string;
};
error?: string;
base_url: string;
auth_expired?: boolean;
};
page?: {
page_id: string;
page_title: string;
space_id: string;
connector_id?: number;
document_id?: number;
indexed_at?: string;
};
error?: string;
}
interface SuccessResult {
@ -77,7 +65,7 @@ interface InsufficientPermissionsResult {
}
type DeleteConfluencePageResult =
| InterruptResult
| InterruptResult<DeleteConfluencePageInterruptContext>
| SuccessResult
| ErrorResult
| NotFoundResult
@ -85,15 +73,6 @@ type DeleteConfluencePageResult =
| AuthErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -145,12 +124,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<DeleteConfluencePageInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -402,18 +377,15 @@ export const DeleteConfluencePageToolUI = ({
{ page_title_or_id: string; delete_from_kb?: boolean },
DeleteConfluencePageResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<DeleteConfluencePageInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -8,39 +8,27 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
type UpdateConfluencePageInterruptContext = {
account?: {
id: number;
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: {
id: number;
name: string;
base_url: string;
auth_expired?: boolean;
};
page?: {
page_id: string;
page_title: string;
space_id: string;
body: string;
version: number;
document_id: number;
indexed_at?: string;
};
error?: string;
base_url: string;
auth_expired?: boolean;
};
page?: {
page_id: string;
page_title: string;
space_id: string;
body: string;
version: number;
document_id: number;
indexed_at?: string;
};
error?: string;
}
interface SuccessResult {
@ -74,22 +62,13 @@ interface InsufficientPermissionsResult {
}
type UpdateConfluencePageResult =
| InterruptResult
| InterruptResult<UpdateConfluencePageInterruptContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -136,12 +115,8 @@ function ApprovalCard({
new_title?: string;
new_content?: string;
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<UpdateConfluencePageInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -502,18 +477,16 @@ export const UpdateConfluencePageToolUI = ({
},
UpdateConfluencePageResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<UpdateConfluencePageInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface DropboxAccount {
id: number;
@ -29,21 +31,11 @@ interface SupportedType {
label: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: DropboxAccount[];
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
supported_types?: SupportedType[];
error?: string;
};
type DropboxCreateFileContext = {
accounts?: DropboxAccount[];
parent_folders?: Record<number, Array<{ folder_path: string; name: string }>>;
supported_types?: SupportedType[];
error?: string;
}
interface SuccessResult {
@ -65,16 +57,7 @@ interface AuthErrorResult {
connector_type?: string;
}
type CreateDropboxFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
type CreateDropboxFileResult = InterruptResult<DropboxCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult {
return (
@ -100,12 +83,8 @@ function ApprovalCard({
onDecision,
}: {
args: { name: string; file_type?: string; content?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<DropboxCreateFileContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -455,17 +434,14 @@ export const CreateDropboxFileToolUI = ({
{ name: string; file_type?: string; content?: string },
CreateDropboxFileResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<DropboxCreateFileContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,6 +7,8 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface DropboxAccount {
id: number;
@ -22,13 +24,10 @@ interface DropboxFile {
document_id?: number;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
context?: { account?: DropboxAccount; file?: DropboxFile; error?: string };
type DropboxTrashFileContext = {
account?: DropboxAccount;
file?: DropboxFile;
error?: string;
}
interface SuccessResult {
@ -52,20 +51,12 @@ interface AuthErrorResult {
}
type DeleteDropboxFileResult =
| InterruptResult
| InterruptResult<DropboxTrashFileContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -95,12 +86,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<DropboxTrashFileContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -308,16 +295,13 @@ export const DeleteDropboxFileToolUI = ({
{ file_name: string; delete_from_kb?: boolean },
DeleteDropboxFileResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<DropboxTrashFileContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -0,0 +1,264 @@
"use client";
import type { ToolCallMessagePartComponent } from "@assistant-ui/react";
import { CornerDownLeftIcon, Pen } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
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 { useHitlPhase } from "@/hooks/use-hitl-phase";
import { connectorsApiService } from "@/lib/apis/connectors-api.service";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } 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 = toolName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
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((err) => {
console.error("Failed to trust MCP tool:", err);
});
}, [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)}
>
<Pen 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>
</>
)}
{/* Parameters */}
{Object.keys(args).length > 0 && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-2">
<p className="text-xs font-medium text-muted-foreground">Parameters</p>
{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

@ -16,6 +16,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -25,22 +27,9 @@ interface GmailAccount {
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GmailAccount[];
error?: string;
};
type GmailCreateDraftContext = {
accounts?: GmailAccount[];
error?: string;
}
interface SuccessResult {
@ -68,21 +57,12 @@ interface InsufficientPermissionsResult {
}
type CreateGmailDraftResult =
| InterruptResult
| InterruptResult<GmailCreateDraftContext>
| SuccessResult
| ErrorResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -116,12 +96,8 @@ function ApprovalCard({
onDecision,
}: {
args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<GmailCreateDraftContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -473,18 +449,16 @@ export const CreateGmailDraftToolUI = ({
{ to: string; subject: string; body: string; cc?: string; bcc?: string },
CreateGmailDraftResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<GmailCreateDraftContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -25,22 +27,9 @@ interface GmailAccount {
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GmailAccount[];
error?: string;
};
type GmailSendEmailContext = {
accounts?: GmailAccount[];
error?: string;
}
interface SuccessResult {
@ -67,21 +56,12 @@ interface InsufficientPermissionsResult {
}
type SendGmailEmailResult =
| InterruptResult
| InterruptResult<GmailSendEmailContext>
| SuccessResult
| ErrorResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -115,12 +95,8 @@ function ApprovalCard({
onDecision,
}: {
args: { to: string; subject: string; body: string; cc?: string; bcc?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<GmailSendEmailContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -471,18 +447,16 @@ export const SendGmailEmailToolUI = ({
{ to: string; subject: string; body: string; cc?: string; bcc?: string },
SendGmailEmailResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<GmailSendEmailContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -6,6 +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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -25,23 +27,10 @@ interface GmailMessage {
document_id: number;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GmailAccount;
email?: GmailMessage;
error?: string;
};
type GmailTrashEmailContext = {
account?: GmailAccount;
email?: GmailMessage;
error?: string;
}
interface SuccessResult {
@ -74,22 +63,13 @@ interface InsufficientPermissionsResult {
}
type TrashGmailEmailResult =
| InterruptResult
| InterruptResult<GmailTrashEmailContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -134,12 +114,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<GmailTrashEmailContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -385,18 +361,15 @@ export const TrashGmailEmailToolUI = ({
{ email_subject_or_id: string; delete_from_kb?: boolean },
TrashGmailEmailResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<GmailTrashEmailContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -9,6 +9,8 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GmailAccount {
@ -28,25 +30,12 @@ interface GmailMessage {
document_id: number;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
account?: GmailAccount;
email?: GmailMessage;
draft_id?: string;
existing_body?: string;
error?: string;
};
type GmailUpdateDraftContext = {
account?: GmailAccount;
email?: GmailMessage;
draft_id?: string;
existing_body?: string;
error?: string;
}
interface SuccessResult {
@ -78,22 +67,13 @@ interface InsufficientPermissionsResult {
}
type UpdateGmailDraftResult =
| InterruptResult
| InterruptResult<GmailUpdateDraftContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -143,12 +123,8 @@ function ApprovalCard({
cc?: string;
bcc?: string;
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<GmailUpdateDraftContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -522,20 +498,16 @@ export const UpdateGmailDraftToolUI = ({
},
UpdateGmailDraftResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
})
);
}}
interruptData={result as InterruptResult<GmailUpdateDraftContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount {
@ -30,24 +32,11 @@ interface CalendarEntry {
primary?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GoogleCalendarAccount[];
calendars?: CalendarEntry[];
timezone?: string;
error?: string;
};
type CalendarCreateEventContext = {
accounts?: GoogleCalendarAccount[];
calendars?: CalendarEntry[];
timezone?: string;
error?: string;
}
interface SuccessResult {
@ -75,21 +64,12 @@ interface InsufficientPermissionsResult {
}
type CreateCalendarEventResult =
| InterruptResult
| InterruptResult<CalendarCreateEventContext>
| SuccessResult
| ErrorResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -141,12 +121,8 @@ function ApprovalCard({
location?: string;
attendees?: string[];
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<CalendarCreateEventContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -620,18 +596,16 @@ export const CreateCalendarEventToolUI = ({
},
CreateCalendarEventResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<CalendarCreateEventContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -6,6 +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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount {
@ -27,23 +29,10 @@ interface CalendarEvent {
indexed_at?: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GoogleCalendarAccount;
event?: CalendarEvent;
error?: string;
};
type CalendarDeleteEventContext = {
account?: GoogleCalendarAccount;
event?: CalendarEvent;
error?: string;
}
interface SuccessResult {
@ -83,7 +72,7 @@ interface InsufficientPermissionsResult {
}
type DeleteCalendarEventResult =
| InterruptResult
| InterruptResult<CalendarDeleteEventContext>
| SuccessResult
| ErrorResult
| NotFoundResult
@ -91,15 +80,6 @@ type DeleteCalendarEventResult =
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -162,12 +142,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<CalendarDeleteEventContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -437,18 +413,15 @@ export const DeleteCalendarEventToolUI = ({
{ event_title_or_id: string; delete_from_kb?: boolean },
DeleteCalendarEventResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<CalendarDeleteEventContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { HitlDecision, InterruptResult } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface GoogleCalendarAccount {
@ -37,23 +39,10 @@ interface CalendarEvent {
indexed_at?: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
account?: GoogleCalendarAccount;
event?: CalendarEvent;
error?: string;
};
type CalendarUpdateEventContext = {
account?: GoogleCalendarAccount;
event?: CalendarEvent;
error?: string;
}
interface SuccessResult {
@ -86,22 +75,13 @@ interface InsufficientPermissionsResult {
}
type UpdateCalendarEventResult =
| InterruptResult
| InterruptResult<CalendarUpdateEventContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -163,12 +143,8 @@ function ApprovalCard({
new_location?: string;
new_attendees?: string[];
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<CalendarUpdateEventContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const actionArgs = interruptData.action_requests[0]?.args ?? {};
@ -686,18 +662,16 @@ export const UpdateCalendarEventToolUI = ({
},
UpdateCalendarEventResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<CalendarUpdateEventContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface GoogleDriveAccount {
id: number;
@ -23,24 +25,11 @@ interface GoogleDriveAccount {
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: GoogleDriveAccount[];
supported_types?: string[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string;
};
type DriveCreateFileContext = {
accounts?: GoogleDriveAccount[];
supported_types?: string[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string;
}
interface SuccessResult {
@ -69,21 +58,12 @@ interface AuthErrorResult {
}
type CreateGoogleDriveFileResult =
| InterruptResult
| InterruptResult<DriveCreateFileContext>
| SuccessResult
| ErrorResult
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -122,12 +102,8 @@ function ApprovalCard({
onDecision,
}: {
args: { name: string; file_type: string; content?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<DriveCreateFileContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -499,18 +475,15 @@ export const CreateGoogleDriveFileToolUI = ({
{ name: string; file_type: string; content?: string },
CreateGoogleDriveFileResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<DriveCreateFileContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,6 +7,8 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface GoogleDriveAccount {
id: number;
@ -21,23 +23,10 @@ interface GoogleDriveFile {
web_view_link: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
context?: {
account?: GoogleDriveAccount;
file?: GoogleDriveFile;
error?: string;
};
type DriveTrashFileContext = {
account?: GoogleDriveAccount;
file?: GoogleDriveFile;
error?: string;
}
interface SuccessResult {
@ -77,7 +66,7 @@ interface AuthErrorResult {
}
type DeleteGoogleDriveFileResult =
| InterruptResult
| InterruptResult<DriveTrashFileContext>
| SuccessResult
| WarningResult
| ErrorResult
@ -85,15 +74,6 @@ type DeleteGoogleDriveFileResult =
| InsufficientPermissionsResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -151,12 +131,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<DriveTrashFileContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -416,18 +392,14 @@ export const DeleteGoogleDriveFileToolUI = ({
{ file_name: string; delete_from_kb?: boolean },
DeleteGoogleDriveFileResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<DriveTrashFileContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -15,6 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraAccount {
@ -40,26 +42,12 @@ interface JiraPriority {
name: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
accounts?: JiraAccount[];
projects?: JiraProject[];
issue_types?: JiraIssueType[];
priorities?: JiraPriority[];
error?: string;
};
type CreateJiraIssueInterruptContext = {
accounts?: JiraAccount[];
projects?: JiraProject[];
issue_types?: JiraIssueType[];
priorities?: JiraPriority[];
error?: string;
}
interface SuccessResult {
@ -88,21 +76,12 @@ interface InsufficientPermissionsResult {
}
type CreateJiraIssueResult =
| InterruptResult
| InterruptResult<CreateJiraIssueInterruptContext>
| SuccessResult
| ErrorResult
| AuthErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -142,12 +121,8 @@ function ApprovalCard({
description?: string;
priority?: string;
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<CreateJiraIssueInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -549,18 +524,16 @@ export const CreateJiraIssueToolUI = ({
},
CreateJiraIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<CreateJiraIssueInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -6,6 +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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraAccount {
@ -23,24 +25,10 @@ interface JiraIssue {
document_id?: number;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: JiraAccount;
issue?: JiraIssue;
error?: string;
};
type DeleteJiraIssueInterruptContext = {
account?: JiraAccount;
issue?: JiraIssue;
error?: string;
}
interface SuccessResult {
@ -79,7 +67,7 @@ interface InsufficientPermissionsResult {
}
type DeleteJiraIssueResult =
| InterruptResult
| InterruptResult<DeleteJiraIssueInterruptContext>
| SuccessResult
| ErrorResult
| NotFoundResult
@ -87,15 +75,6 @@ type DeleteJiraIssueResult =
| AuthErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -147,12 +126,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<DeleteJiraIssueInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -399,18 +374,15 @@ export const DeleteJiraIssueToolUI = ({
{ issue_title_or_key: string; delete_from_kb?: boolean },
DeleteJiraIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<DeleteJiraIssueInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
interface JiraIssue {
@ -43,25 +45,11 @@ interface JiraPriority {
name: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
account?: JiraAccount;
issue?: JiraIssue;
priorities?: JiraPriority[];
error?: string;
};
type UpdateJiraIssueInterruptContext = {
account?: JiraAccount;
issue?: JiraIssue;
priorities?: JiraPriority[];
error?: string;
}
interface SuccessResult {
@ -95,22 +83,13 @@ interface InsufficientPermissionsResult {
}
type UpdateJiraIssueResult =
| InterruptResult
| InterruptResult<UpdateJiraIssueInterruptContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult
| InsufficientPermissionsResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -158,12 +137,8 @@ function ApprovalCard({
new_description?: string;
new_priority?: string;
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<UpdateJiraIssueInterruptContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -563,18 +538,16 @@ export const UpdateJiraIssueToolUI = ({
},
UpdateJiraIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<UpdateJiraIssueInterruptContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -18,6 +18,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel {
id: string;
@ -64,23 +66,9 @@ interface LinearWorkspace {
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspaces?: LinearWorkspace[];
error?: string;
};
type LinearCreateIssueContext = {
workspaces?: LinearWorkspace[];
error?: string;
}
interface SuccessResult {
@ -103,16 +91,7 @@ interface AuthErrorResult {
connector_type: string;
}
type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
type CreateLinearIssueResult = InterruptResult<LinearCreateIssueContext> | SuccessResult | ErrorResult | AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult {
return (
@ -138,12 +117,8 @@ function ApprovalCard({
onDecision,
}: {
args: { title: string; description?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<LinearCreateIssueContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -609,18 +584,16 @@ export const CreateLinearIssueToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ title: string; description?: string }, CreateLinearIssueResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<LinearCreateIssueContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,32 +7,20 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string };
issue?: {
id: string;
identifier: string;
title: string;
state?: string;
document_id?: number;
indexed_at?: string;
};
error?: string;
type LinearDeleteIssueContext = {
workspace?: { id: number; organization_name: string };
issue?: {
id: string;
identifier: string;
title: string;
state?: string;
document_id?: number;
indexed_at?: string;
};
error?: string;
}
interface SuccessResult {
@ -65,22 +53,13 @@ interface AuthErrorResult {
}
type DeleteLinearIssueResult =
| InterruptResult
| InterruptResult<LinearDeleteIssueContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| WarningResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -123,12 +102,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<LinearDeleteIssueContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -366,18 +341,15 @@ export const DeleteLinearIssueToolUI = ({
{ issue_ref: string; delete_from_kb?: boolean },
DeleteLinearIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<LinearDeleteIssueContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -18,6 +18,8 @@ import {
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface LinearLabel {
id: string;
@ -45,45 +47,31 @@ interface LinearPriority {
label: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
name: string;
args: Record<string, unknown>;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
context?: {
workspace?: { id: number; organization_name: string };
priorities?: LinearPriority[];
issue?: {
id: string;
identifier: string;
title: string;
description?: string;
priority: number;
url: string;
current_state?: LinearState;
current_assignee?: { id: string; name: string; email: string } | null;
current_labels?: LinearLabel[];
team_id: string;
document_id: number;
};
team?: {
id: string;
name: string;
key: string;
states: LinearState[];
members: LinearMember[];
labels: LinearLabel[];
};
error?: string;
type LinearUpdateIssueContext = {
workspace?: { id: number; organization_name: string };
priorities?: LinearPriority[];
issue?: {
id: string;
identifier: string;
title: string;
description?: string;
priority: number;
url: string;
current_state?: LinearState;
current_assignee?: { id: string; name: string; email: string } | null;
current_labels?: LinearLabel[];
team_id: string;
document_id: number;
};
team?: {
id: string;
name: string;
key: string;
states: LinearState[];
members: LinearMember[];
labels: LinearLabel[];
};
error?: string;
}
interface SuccessResult {
@ -111,21 +99,12 @@ interface AuthErrorResult {
}
type UpdateLinearIssueResult =
| InterruptResult
| InterruptResult<LinearUpdateIssueContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -167,12 +146,8 @@ function ApprovalCard({
new_priority?: number;
new_label_names?: string[];
};
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<LinearUpdateIssueContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
@ -752,18 +727,16 @@ export const UpdateLinearIssueToolUI = ({
},
UpdateLinearIssueResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<LinearUpdateIssueContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,41 +16,27 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
type NotionCreatePageContext = {
accounts?: Array<{
id: number;
name: string;
args: Record<string, unknown>;
description?: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
auth_expired?: boolean;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
accounts?: Array<{
id: number;
name: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
auth_expired?: boolean;
}>;
parent_pages?: Record<
number,
Array<{
page_id: string;
title: string;
document_id: number;
}>
>;
error?: string;
};
parent_pages?: Record<
number,
Array<{
page_id: string;
title: string;
document_id: number;
}>
>;
error?: string;
}
interface SuccessResult {
@ -75,16 +61,7 @@ interface AuthErrorResult {
connector_type: string;
}
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
type CreateNotionPageResult = InterruptResult<NotionCreatePageContext> | SuccessResult | ErrorResult | AuthErrorResult;
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
@ -110,12 +87,8 @@ function ApprovalCard({
onDecision,
}: {
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<NotionCreatePageContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -449,19 +422,16 @@ export const CreateNotionPageToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ title: string; content: string }, CreateNotionPageResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<NotionCreatePageContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,36 +7,22 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{
type NotionDeletePageContext = {
account?: {
id: number;
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
account?: {
id: number;
name: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
}
interface SuccessResult {
@ -73,22 +59,13 @@ interface AuthErrorResult {
}
type DeleteNotionPageResult =
| InterruptResult
| InterruptResult<NotionDeletePageContext>
| SuccessResult
| ErrorResult
| InfoResult
| WarningResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -131,12 +108,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<NotionDeletePageContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -378,18 +351,15 @@ export const DeleteNotionPageToolUI = ({
{ page_title: string; delete_from_kb?: boolean },
DeleteNotionPageResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<NotionDeletePageContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -9,36 +9,22 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{
type NotionUpdatePageContext = {
account?: {
id: number;
name: string;
args: Record<string, unknown>;
description?: string;
}>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
interrupt_type?: string;
message?: string;
context?: {
account?: {
id: number;
name: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
};
page_id?: string;
current_title?: string;
document_id?: number;
indexed_at?: string;
error?: string;
}
interface SuccessResult {
@ -69,21 +55,12 @@ interface AuthErrorResult {
}
type UpdateNotionPageResult =
| InterruptResult
| InterruptResult<NotionUpdatePageContext>
| SuccessResult
| ErrorResult
| InfoResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -117,12 +94,8 @@ function ApprovalCard({
onDecision,
}: {
args: Record<string, unknown>;
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<NotionUpdatePageContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -399,19 +372,16 @@ export const UpdateNotionPageToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ page_title: string; content: string }, UpdateNotionPageResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
const event = new CustomEvent("hitl-decision", {
detail: { decisions: [decision] },
});
window.dispatchEvent(event);
}}
interruptData={result as InterruptResult<NotionUpdatePageContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -16,6 +16,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useHitlPhase } from "@/hooks/use-hitl-phase";
import { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface OneDriveAccount {
id: number;
@ -24,20 +26,10 @@ interface OneDriveAccount {
auth_expired?: boolean;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}>;
context?: {
accounts?: OneDriveAccount[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string;
};
type OneDriveCreateFileContext = {
accounts?: OneDriveAccount[];
parent_folders?: Record<number, Array<{ folder_id: string; name: string }>>;
error?: string;
}
interface SuccessResult {
@ -59,16 +51,7 @@ interface AuthErrorResult {
connector_type?: string;
}
type CreateOneDriveFileResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
type CreateOneDriveFileResult = InterruptResult<OneDriveCreateFileContext> | SuccessResult | ErrorResult | AuthErrorResult;
function isErrorResult(result: unknown): result is ErrorResult {
return (
@ -94,12 +77,8 @@ function ApprovalCard({
onDecision,
}: {
args: { name: string; content?: string };
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<OneDriveCreateFileContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [isPanelOpen, setIsPanelOpen] = useState(false);
@ -434,17 +413,14 @@ export const CreateOneDriveFileToolUI = ({
args,
result,
}: ToolCallMessagePartProps<{ name: string; content?: string }, CreateOneDriveFileResult>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
args={args}
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<OneDriveCreateFileContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -7,6 +7,8 @@ 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 { isInterruptResult, useHitlDecision } from "@/lib/hitl";
import type { InterruptResult, HitlDecision } from "@/lib/hitl";
interface OneDriveAccount {
id: number;
@ -22,13 +24,10 @@ interface OneDriveFile {
web_url?: string;
}
interface InterruptResult {
__interrupt__: true;
__decided__?: "approve" | "reject";
__completed__?: boolean;
action_requests: Array<{ name: string; args: Record<string, unknown> }>;
review_configs: Array<{ action_name: string; allowed_decisions: Array<"approve" | "reject"> }>;
context?: { account?: OneDriveAccount; file?: OneDriveFile; error?: string };
type OneDriveTrashFileContext = {
account?: OneDriveAccount;
file?: OneDriveFile;
error?: string;
}
interface SuccessResult {
@ -52,20 +51,11 @@ interface AuthErrorResult {
}
type DeleteOneDriveFileResult =
| InterruptResult
| InterruptResult<OneDriveTrashFileContext>
| SuccessResult
| ErrorResult
| NotFoundResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -95,12 +85,8 @@ function ApprovalCard({
interruptData,
onDecision,
}: {
interruptData: InterruptResult;
onDecision: (decision: {
type: "approve" | "reject";
message?: string;
edited_action?: { name: string; args: Record<string, unknown> };
}) => void;
interruptData: InterruptResult<OneDriveTrashFileContext>;
onDecision: (decision: HitlDecision) => void;
}) {
const { phase, setProcessing, setRejected } = useHitlPhase(interruptData);
const [deleteFromKb, setDeleteFromKb] = useState(false);
@ -311,16 +297,13 @@ export const DeleteOneDriveFileToolUI = ({
{ file_name: string; delete_from_kb?: boolean },
DeleteOneDriveFileResult
>) => {
const { dispatch } = useHitlDecision();
if (!result) return null;
if (isInterruptResult(result)) {
return (
<ApprovalCard
interruptData={result}
onDecision={(decision) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions: [decision] } })
);
}}
interruptData={result as InterruptResult<OneDriveTrashFileContext>}
onDecision={(decision) => dispatch([decision])}
/>
);
}

View file

@ -404,6 +404,45 @@ class ConnectorsApiService {
listDiscordChannelsResponse
);
};
// =============================================================================
// MCP Tool Trust (Allow-List) Methods
// =============================================================================
/**
* Add a tool to the MCP connector's "Always Allow" list.
* Subsequent calls to this tool will skip HITL approval.
*/
trustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const token =
typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined;
await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/trust-tool`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ tool_name: toolName }),
});
};
/**
* Remove a tool from the MCP connector's "Always Allow" list.
*/
untrustMCPTool = async (connectorId: number, toolName: string): Promise<void> => {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const token =
typeof window !== "undefined" ? document.cookie.match(/fapiToken=([^;]+)/)?.[1] : undefined;
await fetch(`${backendUrl}/api/v1/connectors/mcp/${connectorId}/untrust-tool`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ tool_name: toolName }),
});
};
}
export type { SlackChannel, DiscordChannel };

View file

@ -148,9 +148,10 @@ export function addToolCall(
toolsWithUI: Set<string>,
toolCallId: string,
toolName: string,
args: Record<string, unknown>
args: Record<string, unknown>,
force = false,
): void {
if (toolsWithUI.has(toolName)) {
if (force || toolsWithUI.has(toolName)) {
state.contentParts.push({
type: "tool-call",
toolCallId,
@ -175,13 +176,20 @@ export function updateToolCall(
}
}
function _hasInterruptResult(part: ContentPart): boolean {
if (part.type !== "tool-call") return false;
const r = (part as { result?: unknown }).result;
return typeof r === "object" && r !== null && "__interrupt__" in r;
}
export function buildContentForUI(
state: ContentPartsState,
toolsWithUI: Set<string>
): ThreadMessageLike["content"] {
const filtered = state.contentParts.filter((part) => {
if (part.type === "text") return part.text.length > 0;
if (part.type === "tool-call") return toolsWithUI.has(part.toolName);
if (part.type === "tool-call")
return toolsWithUI.has(part.toolName) || _hasInterruptResult(part);
if (part.type === "data-thinking-steps") return true;
return false;
});
@ -199,7 +207,10 @@ export function buildContentForPersistence(
for (const part of state.contentParts) {
if (part.type === "text" && part.text.length > 0) {
parts.push(part);
} else if (part.type === "tool-call" && toolsWithUI.has(part.toolName)) {
} else if (
part.type === "tool-call" &&
(toolsWithUI.has(part.toolName) || _hasInterruptResult(part))
) {
parts.push(part);
} else if (part.type === "data-thinking-steps") {
parts.push(part);

View file

@ -0,0 +1,8 @@
export { isInterruptResult } from "./types";
export type {
HitlDecision,
InterruptActionRequest,
InterruptResult,
InterruptReviewConfig,
} from "./types";
export { useHitlDecision } from "./use-hitl-decision";

View file

@ -0,0 +1,45 @@
/**
* Shared types for Human-in-the-Loop (HITL) approval across all tools.
*
* Every tool-ui component that handles interrupts should import from here
* instead of defining its own `InterruptResult` / `isInterruptResult`.
*/
export interface InterruptActionRequest {
name: string;
args: Record<string, unknown>;
}
export interface InterruptReviewConfig {
action_name: string;
allowed_decisions: Array<"approve" | "edit" | "reject">;
}
export interface InterruptResult<C extends Record<string, unknown> = Record<string, unknown>> {
__interrupt__: true;
__decided__?: "approve" | "reject" | "edit";
__completed__?: boolean;
action_requests: InterruptActionRequest[];
review_configs: InterruptReviewConfig[];
interrupt_type?: string;
context?: C;
message?: string;
}
export function isInterruptResult(result: unknown): result is InterruptResult {
return (
typeof result === "object" &&
result !== null &&
"__interrupt__" in result &&
(result as InterruptResult).__interrupt__ === true
);
}
export interface HitlDecision {
type: "approve" | "reject" | "edit";
message?: string;
edited_action?: {
name: string;
args: Record<string, unknown>;
};
}

View file

@ -0,0 +1,19 @@
/**
* Shared hook for dispatching HITL decisions.
*
* All tool-ui components that handle approve/reject/edit should use this
* instead of manually constructing `CustomEvent("hitl-decision", ...)`.
*/
import { useCallback } from "react";
import type { HitlDecision } from "./types";
export function useHitlDecision() {
const dispatch = useCallback((decisions: HitlDecision[]) => {
window.dispatchEvent(
new CustomEvent("hitl-decision", { detail: { decisions } }),
);
}, []);
return { dispatch };
}

View file

@ -737,8 +737,8 @@
"back_to_app": "Back to app",
"nav_general": "General",
"nav_general_desc": "Name, description & basic info",
"nav_agent_configs": "Agent Configs",
"nav_agent_configs_desc": "Models with prompts & citations",
"nav_agent_models": "Agent Models",
"nav_agent_models_desc": "Models with prompts & citations",
"nav_role_assignments": "Role Assignments",
"nav_role_assignments_desc": "Assign configs to agent roles",
"nav_image_models": "Image Models",

View file

@ -737,8 +737,8 @@
"back_to_app": "Volver a la app",
"nav_general": "General",
"nav_general_desc": "Nombre, descripción e información básica",
"nav_agent_configs": "Configuraciones de agente",
"nav_agent_configs_desc": "Modelos LLM con prompts y citas",
"nav_agent_models": "Modelos de agente",
"nav_agent_models_desc": "Modelos LLM con prompts y citas",
"nav_role_assignments": "Asignaciones de roles",
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
"nav_image_models": "Modelos de imagen",

View file

@ -737,8 +737,8 @@
"back_to_app": "ऐप पर वापस जाएं",
"nav_general": "सामान्य",
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
"nav_agent_configs": "एजेंट कॉन्फ़िगरेशन",
"nav_agent_configs_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
"nav_agent_models": "एजेंट मॉडल",
"nav_agent_models_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
"nav_role_assignments": "भूमिका असाइनमेंट",
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
"nav_image_models": "इमेज मॉडल",

View file

@ -737,8 +737,8 @@
"back_to_app": "Voltar ao app",
"nav_general": "Geral",
"nav_general_desc": "Nome, descrição e informações básicas",
"nav_agent_configs": "Configurações do agente",
"nav_agent_configs_desc": "Modelos LLM com prompts e citações",
"nav_agent_models": "Modelos do agente",
"nav_agent_models_desc": "Modelos LLM com prompts e citações",
"nav_role_assignments": "Atribuições de funções",
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
"nav_image_models": "Modelos de imagem",

View file

@ -721,8 +721,8 @@
"back_to_app": "返回应用",
"nav_general": "常规",
"nav_general_desc": "名称、描述和基本信息",
"nav_agent_configs": "代理配置",
"nav_agent_configs_desc": "LLM 模型配置提示词和引用",
"nav_agent_models": "代理模型",
"nav_agent_models_desc": "LLM 模型配置提示词和引用",
"nav_role_assignments": "角色分配",
"nav_role_assignments_desc": "为代理角色分配配置",
"nav_image_models": "图像模型",