mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-04-25 00:36:31 +02:00
Merge commit 'e1e4bb4706' into dev_mod
This commit is contained in:
commit
5d3142332b
85 changed files with 2357 additions and 3132 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue