feat(web): explain or translate selected text in the sidebar

Selecting note text offers explain, translate, and ask. Explain and translate pin the passage and reply in the current companion thread. A translation replaces the selection only while the original range still matches.
This commit is contained in:
tianma-if committed 2026-09-30 01:49:59 +08:00
1 parent caba871c36
commit 58de9ec695
12 files changed
+725 -71

No files matched your search

+197 -46
View File
@@ -52,6 +52,7 @@ import {
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { EditorToolbar } from "./EditorToolbar";
import { EditorOutline } from "./EditorOutline";
import { SelectionAiActions } from "./editor/SelectionAiActions";
import { useAiBubbleMenu } from "./editor/useAiBubbleMenu";
import {
createEditorInstanceMemoIdentity,
@@ -135,6 +136,8 @@ import {
type MemoEditSession,
type TiptapDoc,
createMemoLinkHref,
getAiDocumentFingerprint,
isAiSelectionSnapshotCurrent,
parseMemoLinkHref,
} from "@edgeever/shared";
import { NEW_IMAGE_WIDTH_PERCENT } from "@edgeever/shared/image-display";
@@ -202,6 +205,16 @@ import {
getRichTextAiSelectionContext,
getRichTextAiSelectionReplacement,
} from "@/lib/ai-selection-replacement";
import {
AI_SELECTION_MENU_CHANGED_EVENT,
readAiSelectionMenuPreference,
} from "@/lib/ai-selection-menu-preference";
import {
clipSelectionForSend,
translationReplacement,
type SelectionAiPin,
type SelectionAiRequest,
} from "@/lib/selection-ai";
import { getAttachmentResourceId } from "@/lib/attachment-links";
import {
getAttachmentHoverTarget,
@@ -441,8 +454,13 @@ const RichEditorPane = ({
setAiAssistantOpenState(open);
writeAiSidebarOpen(open);
}, []);
const aiBubbleMenu = useAiBubbleMenu(aiAssistantOpen);
const [aiSelectionText, setAiSelectionText] = useState("");
const aiBubbleMenu = useAiBubbleMenu();
const [selectionPin, setSelectionPin] = useState<SelectionAiPin | null>(null);
const [selectionRequest, setSelectionRequest] = useState<SelectionAiRequest | null>(null);
const selectionPinRef = useRef<SelectionAiPin | null>(null);
selectionPinRef.current = selectionPin;
const markdownMenuAnchorRef = useRef<HTMLDivElement | null>(null);
const [markdownAiMenu, setMarkdownAiMenu] = useState<{ top: number; left: number; below: boolean } | null>(null);
const [mobileNotebookSheetOpen, setMobileNotebookSheetOpen] = useState(false);
const [notebookUpdatePending, setNotebookUpdatePending] = useState(false);
const handledPluginNavigationRequestRef = useRef(0);
@@ -1966,49 +1984,167 @@ const RichEditorPane = ({
: memoRef.current?.contentMarkdown ?? "";
}, [editor, getMobilePlainTextValue, markdownSource, useMarkdownSourceEditor, useMobilePlainTextEditor]);
const readLiveAiSelection = useCallback(() => {
if (useMobilePlainTextEditor) {
const source = getMobilePlainTextValue();
const plainTextElement = mobileTextAreaRef.current;
const from = plainTextElement instanceof HTMLTextAreaElement ? plainTextElement.selectionStart : 0;
const to = plainTextElement instanceof HTMLTextAreaElement ? plainTextElement.selectionEnd : from;
return source.slice(from, to).trim();
}
if (useMarkdownSourceEditor) {
const selectionPos = markdownSourceEditorRef.current?.getSelection() ?? { from: 0, to: 0 };
return markdownSource.slice(selectionPos.from, selectionPos.to).trim();
}
if (isEditorReady(editor)) {
return getRichTextAiSelectionContext(editor.state.doc, editor.state.selection)?.contentMarkdown.trim() ?? "";
}
return "";
}, [editor, getMobilePlainTextValue, markdownSource, useMarkdownSourceEditor, useMobilePlainTextEditor]);
const openAiAssistant = useCallback(() => {
if (effectiveReadOnly) return;
setAiSelectionText(readLiveAiSelection());
setAiSidebarOpen(true);
}, [effectiveReadOnly, readLiveAiSelection, setAiSidebarOpen]);
}, [effectiveReadOnly, setAiSidebarOpen]);
useEffect(() => {
openAiAssistantRef.current = openAiAssistant;
}, [openAiAssistant]);
const selectionMemoIdRef = useRef(memo?.id);
useEffect(() => {
if (!aiAssistantOpen) return;
const refresh = () => {
const next = readLiveAiSelection();
setAiSelectionText((current) => current === next ? current : next);
if (selectionMemoIdRef.current === memo?.id) return;
selectionMemoIdRef.current = memo?.id;
setSelectionPin((current) => !current || current.memoId === memo?.id ? current : null);
setSelectionRequest(null);
}, [memo?.id]);
const requestSelectionAi = useCallback((kind: SelectionAiRequest["kind"]) => {
if (effectiveReadOnly || useMobilePlainTextEditor) return;
const currentMemo = memoRef.current;
if (!currentMemo) return;
let pin: SelectionAiPin | null = null;
if (useMarkdownSourceEditor) {
const sourceEditor = markdownSourceEditorRef.current;
if (!sourceEditor) return;
const selection = sourceEditor.getSelection();
const text = sourceEditor.sliceText(selection.from, selection.to);
if (!text.trim() || selection.to <= selection.from) return;
const clipped = clipSelectionForSend(text);
pin = {
memoId: currentMemo.id,
text,
sentText: clipped.sentText,
displayText: clipped.sentText,
truncated: clipped.truncated,
from: selection.from,
to: selection.to,
isInline: true,
documentFingerprint: "",
mode: "markdown",
};
} else if (isEditorReady(editor)) {
const context = getRichTextAiSelectionContext(editor.state.doc, editor.state.selection);
if (!context?.contentMarkdown.trim()) return;
const clipped = clipSelectionForSend(context.contentMarkdown);
const plain = editor.state.doc.textBetween(context.from, context.to, "\n");
const visible = clipSelectionForSend(plain.trim() ? plain : context.contentMarkdown);
pin = {
memoId: currentMemo.id,
text: context.contentMarkdown,
sentText: clipped.sentText,
displayText: visible.sentText,
truncated: visible.truncated,
from: context.from,
to: context.to,
isInline: context.isInline,
documentFingerprint: getAiDocumentFingerprint(editor.state.doc.toJSON()),
mode: "rich",
};
}
if (!pin) return;
setSelectionPin(pin);
setSelectionRequest({ id: crypto.randomUUID(), kind });
setAiSidebarOpen(true);
}, [editor, effectiveReadOnly, setAiSidebarOpen, useMarkdownSourceEditor, useMobilePlainTextEditor]);
const replacePinnedSelection = useCallback((replacement: string) => {
const pin = selectionPinRef.current;
const currentMemo = memoRef.current;
if (!pin || !currentMemo || pin.memoId !== currentMemo.id) return false;
const body = translationReplacement(replacement);
if (!body) return false;
if (pin.mode === "markdown") {
const sourceEditor = markdownSourceEditorRef.current;
if (!sourceEditor) return false;
if (sourceEditor.sliceText(pin.from, pin.to) !== pin.text) return false;
const next = `${sourceEditor.sliceText(0, pin.from)}${body}${sourceEditor.sliceText(pin.to, sourceEditor.getDocumentLength())}`;
sourceEditor.insertText(body, pin.from, pin.to);
setMarkdownSource(next);
markDirty();
setSelectionPin(null);
return true;
}
const currentEditor = editorRef.current;
if (!isEditorReady(currentEditor)) return false;
const document = currentEditor.state.doc.toJSON();
if (!isAiSelectionSnapshotCurrent(
{ from: pin.from, to: pin.to, documentFingerprint: pin.documentFingerprint },
document,
currentEditor.state.doc.content.size,
)) return false;
const content = getRichTextAiSelectionReplacement(body, pin.isInline);
const inserted = currentEditor.chain().focus().insertContentAt({ from: pin.from, to: pin.to }, content).run();
if (!inserted) return false;
setSelectionPin(null);
return true;
}, [markDirty, setMarkdownSource]);
const syncMarkdownAiMenu = useCallback(() => {
if (!useMarkdownSourceEditor || effectiveReadOnly || useMobilePlainTextEditor || !readAiSelectionMenuPreference()) {
setMarkdownAiMenu((current) => current ? null : current);
return;
}
const sourceEditor = markdownSourceEditorRef.current;
const anchor = markdownMenuAnchorRef.current;
if (!sourceEditor || !anchor) {
setMarkdownAiMenu((current) => current ? null : current);
return;
}
const selection = sourceEditor.getSelection();
const text = sourceEditor.sliceText(selection.from, selection.to);
const coords = text.trim() ? sourceEditor.getSelectionCoordinates() : null;
if (!coords) {
setMarkdownAiMenu((current) => current ? null : current);
return;
}
const bounds = anchor.getBoundingClientRect();
const above = coords.top - bounds.top;
const left = Math.min(
Math.max(0, coords.left - bounds.left),
Math.max(0, bounds.width - 196),
);
const next = {
top: above < 48 ? coords.bottom - bounds.top + 8 : above,
left,
below: above < 48,
};
refresh();
const richEditor = !useMobilePlainTextEditor && !useMarkdownSourceEditor && isEditorReady(editor) ? editor : null;
richEditor?.on("selectionUpdate", refresh);
document.addEventListener("selectionchange", refresh);
setMarkdownAiMenu((current) => (
current && current.top === next.top && current.left === next.left && current.below === next.below
? current
: next
));
}, [effectiveReadOnly, useMarkdownSourceEditor, useMobilePlainTextEditor]);
useEffect(() => {
if (!useMarkdownSourceEditor) {
setMarkdownAiMenu(null);
return;
}
let scroller: HTMLElement | null = null;
const bind = () => {
const next = markdownSourceEditorRef.current?.getScrollContainer() ?? null;
if (next === scroller) return;
scroller?.removeEventListener("scroll", syncMarkdownAiMenu);
scroller = next;
scroller?.addEventListener("scroll", syncMarkdownAiMenu, { passive: true });
};
bind();
const timer = window.setInterval(() => {
bind();
if (scroller) window.clearInterval(timer);
}, 200);
window.addEventListener("resize", syncMarkdownAiMenu);
window.addEventListener(AI_SELECTION_MENU_CHANGED_EVENT, syncMarkdownAiMenu);
return () => {
richEditor?.off("selectionUpdate", refresh);
document.removeEventListener("selectionchange", refresh);
window.clearInterval(timer);
scroller?.removeEventListener("scroll", syncMarkdownAiMenu);
window.removeEventListener("resize", syncMarkdownAiMenu);
window.removeEventListener(AI_SELECTION_MENU_CHANGED_EVENT, syncMarkdownAiMenu);
};
}, [aiAssistantOpen, editor, readLiveAiSelection, useMarkdownSourceEditor, useMobilePlainTextEditor]);
}, [memo?.id, syncMarkdownAiMenu, useMarkdownSourceEditor]);
const getCurrentContentJson = useCallback((): TiptapDoc | null => {
if (useMobilePlainTextEditor) {
@@ -4010,7 +4146,7 @@ const RichEditorPane = ({
</div>
</>
) : useMarkdownSourceEditor ? (
<div className="relative min-h-0 flex-1">
<div ref={markdownMenuAnchorRef} className="relative min-h-0 flex-1">
<Suspense fallback={<div className="h-full w-full" />}>
<MarkdownSourceEditor
ref={markdownSourceEditorRef}
@@ -4026,9 +4162,26 @@ const RichEditorPane = ({
openAiAssistant();
}}
onLinkShortcut={openExternalLinkDialog}
onSelectionChange={syncMarkdownAiMenu}
className="absolute inset-0 h-full w-full"
/>
</Suspense>
{markdownAiMenu ? (
<div
className="absolute z-40"
style={{
top: markdownAiMenu.top,
left: markdownAiMenu.left,
transform: markdownAiMenu.below ? undefined : "translateY(calc(-100% - 8px))",
}}
>
<SelectionAiActions
onExplain={() => requestSelectionAi("explain")}
onTranslate={() => requestSelectionAi("translate")}
onAsk={() => requestSelectionAi("ask")}
/>
</div>
) : null}
</div>
) : (
<div
@@ -4043,17 +4196,11 @@ const RichEditorPane = ({
shouldShow={aiBubbleMenu.shouldShow}
options={aiBubbleMenu.options}
>
<Button
type="button"
size="sm"
variant="solid"
className="shadow-lg"
onMouseDown={(event) => event.preventDefault()}
onClick={openAiAssistant}
>
<Sparkles className="h-3.5 w-3.5" />
{t("aiAssistant.openForSelection")}
</Button>
<SelectionAiActions
onExplain={() => requestSelectionAi("explain")}
onTranslate={() => requestSelectionAi("translate")}
onAsk={() => requestSelectionAi("ask")}
/>
</BubbleMenu>
{!isMobileViewport && !effectiveReadOnly && isEditorReady(editor) ? (
<EditorBlockDragHandle editor={editor} />
@@ -4230,7 +4377,11 @@ const RichEditorPane = ({
open={aiAssistantOpen}
onOpenChange={setAiSidebarOpen}
companionAvailable={companionAvailable}
selectionMarkdown={aiSelectionText}
selectionMarkdown={selectionPin?.memoId === memo.id ? selectionPin.sentText : ""}
selectionPin={selectionPin?.memoId === memo.id ? selectionPin : null}
selectionRequest={selectionRequest}
onDismissSelectionPin={() => setSelectionPin(null)}
onReplaceSelection={replacePinnedSelection}
contentMarkdown={currentMarkdownForAi}
memoId={memo.id}
notebookId={memo.notebookId}
+233 -11
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ClipboardEvent,
import { AnimatePresence } from "motion/react";
import * as m from "motion/react-m";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, Loader2, PanelRightClose, Paperclip, Plus, Search, Sparkles } from "lucide-react";
import { Check, ChevronDown, Loader2, PanelRightClose, Paperclip, Plus, Search, Sparkles, X } from "lucide-react";
import type { CompanionAction, CompanionAnswer, CompanionEvent, CompanionTurn, CompanionTurnInput } from "@edgeever/shared";
import { buildRevisionDiffRows, createMemoLinkHref, parseMemoLinkHref } from "@edgeever/shared";
import { Button } from "@/components/ui/button";
@@ -35,6 +35,7 @@ import {
} from "@/components/ai-elements/prompt-input";
import { Reasoning, ReasoningContent, ReasoningTrigger } from "@/components/ai-elements/reasoning";
import { api, ApiRequestError } from "@/lib/api";
import { copyTextToClipboard } from "@/lib/clipboard";
import {
AI_ATTACHMENT_ACCEPT,
AiAttachmentError,
@@ -58,6 +59,14 @@ import {
type DesktopAcpEvent,
} from "@/lib/desktop-acp";
import { sidebarRevealTransition } from "@/lib/motion";
import {
SELECTION_AI_LANGUAGES,
selectionAiUserMessage,
translationReplacement,
type SelectionAiLanguage,
type SelectionAiPin,
type SelectionAiRequest,
} from "@/lib/selection-ai";
import { cn } from "@/lib/utils";
import { CompanionQuestionForm } from "../CompanionQuestionForm";
import { AiSidebarMessage } from "./AiSidebarMessage";
@@ -136,11 +145,34 @@ type AiSidebarProps = AiSidebarFocus & {
open: boolean;
onOpenChange: (open: boolean) => void;
companionAvailable: boolean;
selectionPin?: SelectionAiPin | null;
selectionRequest?: SelectionAiRequest | null;
onDismissSelectionPin?: () => void;
onReplaceSelection?: (replacement: string) => boolean;
beforeCompanionApply?: () => Promise<void>;
onCompanionNotesChanged?: () => Promise<void>;
onOpenCompanionNote?: (id: string, notebookId: string) => void;
};
const SELECTION_TURN_STORAGE = "edgeever.aiSidebar.selectionTurns";
const readSelectionTurnKinds = (): Record<string, "explain" | "translate"> => {
if (typeof window === "undefined") return {};
try {
const raw = window.sessionStorage.getItem(SELECTION_TURN_STORAGE);
if (!raw) return {};
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== "object") return {};
const next: Record<string, "explain" | "translate"> = {};
for (const [id, kind] of Object.entries(parsed)) {
if (kind === "explain" || kind === "translate") next[id] = kind;
}
return next;
} catch {
return {};
}
};
const clampSidebarWidth = (value: number) => Math.min(SIDEBAR_MAX_WIDTH, Math.max(SIDEBAR_MIN_WIDTH, Math.round(value)));
const readStorage = (key: string) => {
@@ -445,6 +477,80 @@ function NoteEditDiff({ before, after }: { before: string; after: string }) {
);
}
function SelectionReplyActions({
kind,
response,
pinned,
busy,
onReplace,
onRetranslate,
}: {
kind: "explain" | "translate";
response: string;
pinned: boolean;
busy: boolean;
onReplace: (replacement: string) => boolean;
onRetranslate: (language: SelectionAiLanguage) => void;
}) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const [stale, setStale] = useState(false);
const copyText = kind === "translate" ? translationReplacement(response) : response.trim();
if (!copyText) return null;
return (
<div className="space-y-1.5" data-selection-reply-actions={kind}>
<div className="flex flex-wrap items-center gap-1.5">
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void copyTextToClipboard(copyText).then((ok) => {
if (!ok) return;
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
});
}}
>
{copied ? t("aiAssistant.sidebar.selection.copied") : t("aiAssistant.sidebar.selection.copy")}
</Button>
{kind === "translate" && pinned ? (
<Button
type="button"
size="sm"
variant="outline"
disabled={busy}
onClick={() => {
if (onReplace(response)) {
setStale(false);
return;
}
setStale(true);
}}
>
{t("aiAssistant.sidebar.selection.replace")}
</Button>
) : null}
{kind === "translate" && pinned ? SELECTION_AI_LANGUAGES.map((language) => (
<Button
key={language}
type="button"
size="sm"
variant="ghost"
disabled={busy}
onClick={() => onRetranslate(language)}
>
{t(`aiAssistant.sidebar.selection.chips.${language}`)}
</Button>
)) : null}
</div>
{stale ? (
<p role="alert" className="text-xs text-rose-700">{t("aiAssistant.sidebar.selection.stale")}</p>
) : null}
</div>
);
}
function SidebarComposer({
attachments,
attachmentError,
@@ -456,6 +562,7 @@ function SidebarComposer({
onLaunch,
onStop,
skillPrompt,
focusToken,
}: {
attachments: PendingAttachment[];
attachmentError: string | null;
@@ -464,13 +571,23 @@ function SidebarComposer({
placeholder: string;
onAddFiles: (files: File[]) => void;
onRemoveAttachment: (id: string) => void;
onLaunch: (message: string) => Promise<void>;
onLaunch: (message: string) => Promise<string>;
onStop: () => void;
skillPrompt: (id: SkillId, rest?: string) => string;
focusToken: number;
}) {
const { t } = useTranslation();
const { textInput } = usePromptInputController();
const fileInputRef = useRef<HTMLInputElement>(null);
const composerRef = useRef<HTMLDivElement>(null);
const focusedToken = useRef(0);
useEffect(() => {
if (!focusToken || busy || focusedToken.current === focusToken) return;
const field = composerRef.current?.querySelector("textarea");
if (!(field instanceof HTMLTextAreaElement)) return;
field.focus();
focusedToken.current = focusToken;
}, [busy, focusToken]);
const draft = textInput.value;
const token = draft.trimStart().split(/\s/, 1)[0]?.toLowerCase() ?? "";
const slashOpen = token.startsWith("/") && !draft.trimStart().includes("\n");
@@ -494,7 +611,7 @@ function SidebarComposer({
};
return (
<div className="space-y-2">
<div ref={composerRef} className="space-y-2">
{slashMatches.length ? (
<ul className="overflow-hidden rounded-md border border-slate-200 bg-card text-sm">
{slashMatches.map((skill) => (
@@ -593,6 +710,10 @@ function AiSidebarSession({
onOpenChange,
companionAvailable,
selectionMarkdown,
selectionPin,
selectionRequest,
onDismissSelectionPin,
onReplaceSelection,
contentMarkdown,
memoId,
notebookId,
@@ -621,7 +742,10 @@ function AiSidebarSession({
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [useMemory, setUseMemory] = useState(false);
const [composerFocusToken, setComposerFocusToken] = useState(0);
const [selectionTurnKinds, setSelectionTurnKinds] = useState<Record<string, "explain" | "translate">>(readSelectionTurnKinds);
const alive = useRef(true);
const handledSelectionRequestId = useRef<string | null>(null);
const locked = useRef(false);
const threadPinned = useRef(false);
const active = useRef<ActiveTurn | null>(null);
@@ -861,7 +985,7 @@ function AiSidebarSession({
onStopReady.current = () => { void stop(); };
}, [onStopReady, stop]);
const launch = useCallback(async (message: string) => {
const launch = useCallback(async (message: string): Promise<string> => {
const text = message.trim();
if (!text || locked.current) throw new Error("busy");
const mode = readAiSidebarSource();
@@ -933,7 +1057,7 @@ function AiSidebarSession({
const queued = acpBuffer.current.filter((event) => event.requestId === result.requestId);
acpBuffer.current = acpBuffer.current.filter((event) => event.requestId !== result.requestId);
for (const event of queued) applyAcpEventRef.current(event);
return;
return id;
}
const uploaded: UploadedCompanionAttachment[] = [];
@@ -997,6 +1121,7 @@ function AiSidebarSession({
}
}
})();
return id;
} catch (cause) {
if (active.current?.id === id) active.current = null;
locked.current = false;
@@ -1020,6 +1145,63 @@ function AiSidebarSession({
}
}, [applyStreamEvent, attachments, buildFocus, contextText, explainError, i18n.resolvedLanguage, recoverTurn, restoreAttachments, t, threadId, useMemory]);
const rememberSelectionTurn = useCallback((id: string, kind: "explain" | "translate") => {
setSelectionTurnKinds((current) => {
const next = { ...current, [id]: kind };
const ids = Object.keys(next);
if (ids.length > 80) {
for (const oldId of ids.slice(0, ids.length - 80)) delete next[oldId];
}
try {
window.sessionStorage.setItem(SELECTION_TURN_STORAGE, JSON.stringify(next));
} catch {
// Session storage can be unavailable in private mode.
}
return next;
});
}, []);
const selectionMessage = useCallback((kind: "explain" | "translate", language?: SelectionAiLanguage) => {
if (!selectionPin) return "";
const quote = selectionPin.displayText || selectionPin.sentText;
const notice = selectionPin.truncated
? t("aiAssistant.sidebar.selection.truncated", { count: Array.from(quote).length })
: "";
const instruction = kind === "explain"
? t("aiAssistant.sidebar.selection.explainPrompt")
: language
? t("aiAssistant.sidebar.selection.translateLanguagePrompt", {
language: t(`aiAssistant.sidebar.selection.languageNames.${language}`),
})
: t("aiAssistant.sidebar.selection.translatePrompt");
return selectionAiUserMessage({ instruction, notice, quote });
}, [selectionPin, t]);
const retranslateSelection = useCallback((language: SelectionAiLanguage) => {
const message = selectionMessage("translate", language);
if (!message || locked.current) return;
void launch(message).then((id) => {
rememberSelectionTurn(id, "translate");
}).catch(() => undefined);
}, [launch, rememberSelectionTurn, selectionMessage]);
useEffect(() => {
const request = selectionRequest;
if (!request || handledSelectionRequestId.current === request.id) return;
if (request.kind === "ask") {
handledSelectionRequestId.current = request.id;
setComposerFocusToken((current) => current + 1);
return;
}
if (!selectionPin || locked.current || loading) return;
const message = selectionMessage(request.kind);
if (!message) return;
handledSelectionRequestId.current = request.id;
void launch(message).then((id) => {
rememberSelectionTurn(id, request.kind === "explain" ? "explain" : "translate");
}).catch(() => undefined);
}, [busy, launch, loading, rememberSelectionTurn, selectionMessage, selectionPin, selectionRequest]);
const addFiles = useCallback(async (files: File[]) => {
if (!files.length) return;
let existing: PreparedAiAttachment[] = attachments;
@@ -1141,7 +1323,6 @@ function AiSidebarSession({
onOpenCompanionNote(linkedId, source.notebookId);
};
const selectionCount = Array.from((selectionMarkdown ?? "").trim()).length;
const threads = useMemo(() => sidebarThreadsFromTurns(turns), [turns]);
const threadTitle = threads.find((thread) => thread.id === threadId)?.title || t("aiAssistant.sidebar.newThread");
const rememberThread = (id: string) => {
@@ -1155,6 +1336,21 @@ function AiSidebarSession({
const visibleTurns = visibleCompanion ? threadTurns : localTurns;
const running = busy || visibleTurns.some((turn) => turn.status === "running");
const renderSelectionReply = (turnId: string, response: string, status: string) => {
const kind = selectionTurnKinds[turnId];
if (!kind || status !== "completed" || !response.trim()) return null;
return (
<SelectionReplyActions
kind={kind}
response={response}
pinned={Boolean(selectionPin)}
busy={running || acting}
onReplace={(replacement) => onReplaceSelection?.(replacement) ?? false}
onRetranslate={retranslateSelection}
/>
);
};
const renderActions = (turnId: string) => actions
.filter((action) => action.turnId === turnId && action.status === "pending")
.map((action) => {
@@ -1208,11 +1404,6 @@ function AiSidebarSession({
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
{selectionCount > 0 ? (
<p className="shrink-0 border-b border-slate-200 px-3 py-1.5 text-xs text-slate-500">
{t("aiAssistant.sidebar.selectionBadge", { count: selectionCount })}
</p>
) : null}
{error ? <p role="alert" className="shrink-0 px-3 pt-2 text-sm text-rose-700">{error}</p> : null}
<Conversation key={source === "builtin" ? threadId : "local"} className="min-h-0 flex-1">
<ConversationContent className={sidebarThreadClassName}>
@@ -1267,6 +1458,7 @@ function AiSidebarSession({
{turn.status === "failed" || turn.status === "cancelled" ? (
<p className="text-xs text-slate-500">{t(`companion.status.${turn.status}`)}</p>
) : null}
{renderSelectionReply(turn.id, turn.response, turn.status)}
{renderActions(turn.id)}
</Message>
</div>
@@ -1296,6 +1488,7 @@ function AiSidebarSession({
</ul>
) : null}
{turn.response ? <AiSidebarMessage isAnimating={turn.status === "running"}>{turn.response}</AiSidebarMessage> : null}
{renderSelectionReply(turn.id, turn.response, turn.status)}
{turn.status === "failed" || turn.status === "cancelled" ? (
<p className="text-xs text-slate-500">{t(`companion.status.${turn.status}`)}</p>
) : null}
@@ -1306,6 +1499,34 @@ function AiSidebarSession({
<ConversationScrollButton />
</Conversation>
<div className="shrink-0 space-y-2 border-t border-slate-200 p-3">
{selectionPin ? (
<div className="flex items-start gap-2 rounded-md border border-slate-200 bg-slate-50 px-2.5 py-2" data-selection-pin="">
<div className="min-w-0 flex-1">
<p className="line-clamp-3 whitespace-pre-wrap break-words text-xs leading-5 text-slate-700">{selectionPin.displayText || selectionPin.sentText}</p>
{selectionPin.truncated ? (
<p className="mt-1 text-xs text-slate-500">
{t("aiAssistant.sidebar.selection.truncated", { count: Array.from(selectionPin.displayText || selectionPin.sentText).length })}
</p>
) : null}
</div>
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
size="icon-xs"
variant="ghost"
aria-label={t("aiAssistant.sidebar.selection.dismiss")}
onClick={() => onDismissSelectionPin?.()}
>
<X className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("aiAssistant.sidebar.selection.dismiss")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : null}
{!companionAvailable && source !== "local" ? (
<p className="text-xs leading-5 text-slate-500">{t("aiAssistant.sidebar.unavailable")}</p>
) : null}
@@ -1335,6 +1556,7 @@ function AiSidebarSession({
onLaunch={launch}
onStop={() => { void stop(); }}
skillPrompt={skillPrompt}
focusToken={composerFocusToken}
/>
</PromptInputProvider>
</div>
@@ -37,6 +37,8 @@ export interface MarkdownSourceEditorRef {
setSelection: (from: number, to: number) => void;
focus: () => void;
insertText: (text: string, from?: number, to?: number) => void;
sliceText: (from: number, to: number) => string;
getDocumentLength: () => number;
getSelectionCoordinates: () => { top: number; left: number; bottom: number; right: number } | null;
}
@@ -52,6 +54,7 @@ export interface MarkdownSourceEditorProps {
ariaLabel?: string;
onSlashCommandTrigger?: (commandStart: number) => void;
onLinkShortcut?: () => void;
onSelectionChange?: () => void;
}
export const CODE_MIRROR_THEME_MAP: Record<MarkdownThemeName, Extension> = {
@@ -122,6 +125,7 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
ariaLabel,
onSlashCommandTrigger,
onLinkShortcut,
onSelectionChange,
},
ref,
) => {
@@ -129,6 +133,8 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
const memoIdRef = useRef(memoId);
memoIdRef.current = memoId;
const pendingPastesRef = useRef(new Set<PendingPaste>());
const onSelectionChangeRef = useRef(onSelectionChange);
onSelectionChangeRef.current = onSelectionChange;
useImperativeHandle(
ref,
@@ -170,6 +176,15 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
scrollIntoView: true,
});
},
sliceText: (from: number, to: number) => {
const view = cmRef.current?.view;
if (!view) return "";
const length = view.state.doc.length;
const safeFrom = Math.max(0, Math.min(from, length));
const safeTo = Math.max(safeFrom, Math.min(to, length));
return view.state.doc.sliceString(safeFrom, safeTo);
},
getDocumentLength: () => cmRef.current?.view?.state.doc.length ?? 0,
getSelectionCoordinates: () => {
const view = cmRef.current?.view;
if (!view) return null;
@@ -197,11 +212,13 @@ export const MarkdownSourceEditor = forwardRef<MarkdownSourceEditorRef, Markdown
EditorView.lineWrapping,
baseEditorTheme,
EditorView.updateListener.of((update) => {
if (!update.docChanged) return;
for (const pending of pendingPastesRef.current) {
pending.from = update.changes.mapPos(pending.from, -1);
pending.to = update.changes.mapPos(pending.to, 1);
if (update.docChanged) {
for (const pending of pendingPastesRef.current) {
pending.from = update.changes.mapPos(pending.from, -1);
pending.to = update.changes.mapPos(pending.to, 1);
}
}
if (update.selectionSet || update.docChanged) onSelectionChangeRef.current?.();
}),
];
}, []);
@@ -0,0 +1,54 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
export function SelectionAiActions({
onExplain,
onTranslate,
onAsk,
}: {
onExplain: () => void;
onTranslate: () => void;
onAsk: () => void;
}) {
const { t } = useTranslation();
return (
<div
role="toolbar"
aria-label={t("aiAssistant.sidebar.selection.actions")}
className="flex items-center gap-0.5 rounded-lg border border-slate-200 bg-card p-1 shadow-lg"
data-selection-ai-menu=""
onMouseDown={(event) => event.preventDefault()}
>
<Button
type="button"
size="sm"
variant="ghost"
className="text-slate-950"
data-selection-ai-action="explain"
onClick={onExplain}
>
{t("aiAssistant.sidebar.selection.explain")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="text-slate-950"
data-selection-ai-action="translate"
onClick={onTranslate}
>
{t("aiAssistant.sidebar.selection.translate")}
</Button>
<Button
type="button"
size="sm"
variant="ghost"
className="font-normal text-slate-500"
data-selection-ai-action="ask"
onClick={onAsk}
>
{t("aiAssistant.sidebar.selection.ask")}
</Button>
</div>
);
}
@@ -7,7 +7,6 @@ import { ImageGallery, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared";
import { hasAiTextSelection, shouldShowAiBubbleMenu } from "./useAiBubbleMenu.ts";
const visibleState = {
assistantOpen: false,
editable: true,
enabled: true,
selectionEmpty: false,
@@ -19,11 +18,10 @@ describe("AI bubble menu visibility", () => {
expect(shouldShowAiBubbleMenu(visibleState)).toBe(true);
});
test("stays hidden when disabled, read-only, empty, or already open", () => {
test("stays hidden when disabled, read-only, empty, or without text", () => {
expect(shouldShowAiBubbleMenu({ ...visibleState, enabled: false })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, editable: false })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, selectionEmpty: true })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, assistantOpen: true })).toBe(false);
expect(shouldShowAiBubbleMenu({ ...visibleState, selectionHasText: false })).toBe(false);
});
@@ -19,21 +19,20 @@ export const hasAiTextSelection = ({ doc, selection }: Pick<EditorState, "doc" |
))
);
// The sidebar can stay open. A later selection still needs these actions.
export const shouldShowAiBubbleMenu = ({
assistantOpen,
editable,
enabled,
selectionEmpty,
selectionHasText,
}: {
assistantOpen: boolean;
editable: boolean;
enabled: boolean;
selectionEmpty: boolean;
selectionHasText: boolean;
}): boolean => enabled && editable && !selectionEmpty && selectionHasText && !assistantOpen;
}): boolean => enabled && editable && !selectionEmpty && selectionHasText;
export const useAiBubbleMenu = (assistantOpen: boolean) => {
export const useAiBubbleMenu = () => {
const [enabled, setEnabled] = useState(readAiSelectionMenuPreference);
useEffect(() => {
@@ -59,13 +58,12 @@ export const useAiBubbleMenu = (assistantOpen: boolean) => {
// toolbar refresh cannot feed back into another BubbleMenu update.
const shouldShow = useCallback<BubbleMenuShouldShow>(
({ editor }) => shouldShowAiBubbleMenu({
assistantOpen,
editable: editor.isEditable,
enabled,
selectionEmpty: editor.state.selection.empty,
selectionHasText: hasAiTextSelection(editor.state),
}),
[assistantOpen, enabled],
[enabled],
);
return {
+1 -1
View File
@@ -22,7 +22,7 @@ import { loadPluginMarketplace } from "@/lib/plugins/plugin-marketplace";
const BubbleMenuTransactionRegression = () => {
const [transactionCount, setTransactionCount] = useState(0);
const aiBubbleMenu = useAiBubbleMenu(false);
const aiBubbleMenu = useAiBubbleMenu();
const editor = useEditor({
extensions: [StarterKit],
content: "<p>Bubble menu regression</p>",
+70
View File
@@ -0,0 +1,70 @@
import { describe, expect, test } from "bun:test";
import {
SELECTION_AI_SEND_LIMIT,
clipSelectionForSend,
selectionAiUserMessage,
translationReplacement,
} from "./selection-ai.ts";
describe("selection send clip", () => {
test("keeps a short passage intact", () => {
expect(clipSelectionForSend("概念")).toEqual({ sentText: "概念", truncated: false });
});
test("clips to the send limit without splitting an emoji", () => {
const emoji = "😀";
const text = `${"a".repeat(SELECTION_AI_SEND_LIMIT - 1)}${emoji}`;
const clipped = clipSelectionForSend(text);
expect(clipped.truncated).toBe(true);
expect(clipped.sentText).toBe("a".repeat(SELECTION_AI_SEND_LIMIT - 1));
expect(clipped.sentText.length).toBeLessThanOrEqual(SELECTION_AI_SEND_LIMIT);
});
test("keeps a passage that already fits, including a trailing emoji", () => {
const text = `${"a".repeat(SELECTION_AI_SEND_LIMIT - 2)}😀`;
expect(text.length).toBe(SELECTION_AI_SEND_LIMIT);
expect(clipSelectionForSend(text).truncated).toBe(false);
});
});
describe("translation replacement", () => {
test("uses the whole reply when it is only the translation", () => {
expect(translationReplacement(" Hello there. ")).toBe("Hello there.");
});
test("drops a short single-line note that sits above a blank line", () => {
const response = "原文已经是简体中文,下面译成英文。\n\nHello there.";
expect(translationReplacement(response)).toBe("Hello there.");
});
test("keeps a long or multi-line opening", () => {
const long = `${"This opening is longer than eighty characters, so the whole reply stays in the replacement."}\n\nNext`;
expect(long.split("\n")[0].length).toBeGreaterThan(80);
expect(translationReplacement(long)).toBe(long.trim());
expect(translationReplacement("Line one\nline two\n\nBody")).toBe("Line one\nline two\n\nBody");
});
test("returns nothing for a blank reply", () => {
expect(translationReplacement(" \n")).toBe("");
});
});
describe("selection user message", () => {
test("places the truncation notice before the quote", () => {
expect(selectionAiUserMessage({
instruction: "请解释下面这段文字。",
notice: "只发送了选区的前 2000 个字符。",
quote: "概念",
})).toBe("请解释下面这段文字。\n\n只发送了选区的前 2000 个字符。\n\n概念");
});
test("stays inside the companion message limit for a full clip", () => {
const message = selectionAiUserMessage({
instruction: "请把下面这段翻译成简体中文。如果原文已经是简体中文,就译成英文,并在第一行说明,空一行后再写译文。否则只写译文。不要修改笔记。",
notice: "只发送了选区的前 2000 个字符。",
quote: "字".repeat(SELECTION_AI_SEND_LIMIT),
});
expect(message.length).toBeLessThanOrEqual(4000);
expect(message.endsWith("字".repeat(SELECTION_AI_SEND_LIMIT))).toBe(true);
});
});
+69
View File
@@ -0,0 +1,69 @@
export const SELECTION_AI_SEND_LIMIT = 2000;
export const SELECTION_AI_LANGUAGES = ["zh-CN", "en", "ja"] as const;
export type SelectionAiLanguage = (typeof SELECTION_AI_LANGUAGES)[number];
export type SelectionAiKind = "explain" | "translate" | "ask";
export type SelectionAiMode = "rich" | "markdown";
export type SelectionAiPin = {
memoId: string;
text: string;
sentText: string;
displayText: string;
truncated: boolean;
from: number;
to: number;
isInline: boolean;
documentFingerprint: string;
mode: SelectionAiMode;
};
export type SelectionAiRequest = {
id: string;
kind: SelectionAiKind;
};
// The companion schema counts UTF-16 code units. Stop one unit early when a
// slice would cut a surrogate pair in half.
export const clipSelectionForSend = (text: string) => {
if (text.length <= SELECTION_AI_SEND_LIMIT) {
return { sentText: text, truncated: false };
}
let end = SELECTION_AI_SEND_LIMIT;
const tail = text.charCodeAt(end - 1);
if (tail >= 0xd800 && tail <= 0xdbff) end -= 1;
return { sentText: text.slice(0, end), truncated: true };
};
// A short single-line first paragraph, followed by a blank line, is the
// "already this language" note. The writable translation is the rest.
const TRANSLATION_NOTE_MAX = 80;
export const translationReplacement = (response: string) => {
const trimmed = response.trim();
if (!trimmed) return "";
const parts = trimmed.split(/\n[ \t]*\n/);
const first = parts[0]?.trim() ?? "";
if (
parts.length > 1
&& first.length > 0
&& first.length <= TRANSLATION_NOTE_MAX
&& !first.includes("\n")
) {
return parts.slice(1).join("\n\n").trim();
}
return trimmed;
};
export const selectionAiUserMessage = ({
instruction,
notice,
quote,
}: {
instruction: string;
notice?: string;
quote: string;
}) => [instruction.trim(), notice?.trim(), quote].filter((part) => part && part.length > 0).join("\n\n");
+25
View File
@@ -1315,6 +1315,31 @@ export const enUS = {
emptyDescription: "Summaries, rewrites, and translations come back as replies. They do not change the note on their own.",
working: "Replying…",
selectionBadge: "{{count}} characters selected · Current note",
selection: {
explain: "Explain",
translate: "Translate",
ask: "Ask",
actions: "Selected text",
explainPrompt: "Explain the ideas in the passage below in English. Do not change the note.",
translatePrompt: "Translate the passage below into English. If it is already English, say so on the first line, leave a blank line, then write the English text. Otherwise write only the translation. Do not change the note.",
translateLanguagePrompt: "Translate the passage below into {{language}}. Write only the translation, with no preamble. Do not change the note.",
truncated: "Only the first {{count}} characters of the selection were sent.",
dismiss: "Unpin",
copy: "Copy",
copied: "Copied",
replace: "Replace selected text",
stale: "The note changed, so this was not written.",
chips: {
"zh-CN": "中文",
en: "English",
ja: "日本語",
},
languageNames: {
"zh-CN": "Simplified Chinese",
en: "English",
ja: "Japanese",
},
},
skills: {
summarize: "Summarize",
improve: "Improve writing",
+25
View File
@@ -1315,6 +1315,31 @@ export const ja = {
emptyDescription: "要約、推敲、翻訳は返信として返ります。ノートは自動では変わりません。",
working: "返信しています…",
selectionBadge: "{{count}} 文字を選択 · 現在のノート",
selection: {
explain: "説明",
translate: "翻訳",
ask: "質問",
actions: "選択した文字",
explainPrompt: "下の文章に出てくる概念を日本語でわかりやすく説明してください。ノートは変更しないでください。",
translatePrompt: "下の文章を日本語に翻訳してください。原文がすでに日本語なら英語に訳し、1行目でその旨を書き、空行のあとに訳文だけを書いてください。そうでなければ訳文だけを書いてください。ノートは変更しないでください。",
translateLanguagePrompt: "下の文章を{{language}}に翻訳してください。説明は書かず、訳文だけを書いてください。ノートは変更しないでください。",
truncated: "選択範囲の先頭 {{count}} 文字だけを送信しました。",
dismiss: "ピン留めを外す",
copy: "コピー",
copied: "コピーしました",
replace: "選択した文字を置き換える",
stale: "ノートが変わったため、今回は書き込んでいません。",
chips: {
"zh-CN": "中文",
en: "English",
ja: "日本語",
},
languageNames: {
"zh-CN": "簡体字中国語",
en: "英語",
ja: "日本語",
},
},
skills: {
summarize: "要約",
improve: "推敲",
+25
View File
@@ -1315,6 +1315,31 @@ export const zhCN = {
emptyDescription: "总结、润色和翻译会作为回复出现,不会直接改掉笔记。",
working: "正在回复…",
selectionBadge: "已选 {{count}} 字 · 当前笔记",
selection: {
explain: "解释",
translate: "翻译",
ask: "询问",
actions: "对选中文字",
explainPrompt: "请解释下面这段文字里的概念,用简体中文写清楚。不要修改笔记。",
translatePrompt: "请把下面这段翻译成简体中文。如果原文已经是简体中文,就译成英文,并在第一行说明,空一行后再写译文。否则只写译文。不要修改笔记。",
translateLanguagePrompt: "请把下面这段翻译成{{language}}。只写译文,不要说明。不要修改笔记。",
truncated: "只发送了选区的前 {{count}} 个字符。",
dismiss: "取消钉选",
copy: "复制",
copied: "已复制",
replace: "替换选中文字",
stale: "笔记已经变化,这次没有写入。",
chips: {
"zh-CN": "中文",
en: "English",
ja: "日本語",
},
languageNames: {
"zh-CN": "简体中文",
en: "英文",
ja: "日文",
},
},
skills: {
summarize: "总结",
improve: "润色",