From 58de9ec69593d1dea35fedd3dc1cb4eee1f3b1eb Mon Sep 17 00:00:00 2001 From: tianma-if Date: Wed, 30 Sep 2026 01:49:59 +0800 Subject: [PATCH] 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. --- apps/web/src/components/EditorPane.tsx | 243 +++++++++++++---- .../src/components/ai-sidebar/AiSidebar.tsx | 244 +++++++++++++++++- .../editor/MarkdownSourceEditor.tsx | 25 +- .../components/editor/SelectionAiActions.tsx | 54 ++++ .../editor/useAiBubbleMenu.test.mjs | 4 +- .../src/components/editor/useAiBubbleMenu.ts | 10 +- apps/web/src/desktop-renderer-test.tsx | 2 +- apps/web/src/lib/selection-ai.test.mjs | 70 +++++ apps/web/src/lib/selection-ai.ts | 69 +++++ packages/shared/src/i18n/en-US.ts | 25 ++ packages/shared/src/i18n/ja.ts | 25 ++ packages/shared/src/i18n/zh-CN.ts | 25 ++ 12 files changed, 725 insertions(+), 71 deletions(-) create mode 100644 apps/web/src/components/editor/SelectionAiActions.tsx create mode 100644 apps/web/src/lib/selection-ai.test.mjs create mode 100644 apps/web/src/lib/selection-ai.ts diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 106289f2..800d7da2 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -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(null); + const [selectionRequest, setSelectionRequest] = useState(null); + const selectionPinRef = useRef(null); + selectionPinRef.current = selectionPin; + const markdownMenuAnchorRef = useRef(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 = ({ ) : useMarkdownSourceEditor ? ( -
+
}> + {markdownAiMenu ? ( +
+ requestSelectionAi("explain")} + onTranslate={() => requestSelectionAi("translate")} + onAsk={() => requestSelectionAi("ask")} + /> +
+ ) : null}
) : (
- + requestSelectionAi("explain")} + onTranslate={() => requestSelectionAi("translate")} + onAsk={() => requestSelectionAi("ask")} + /> {!isMobileViewport && !effectiveReadOnly && isEditorReady(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} diff --git a/apps/web/src/components/ai-sidebar/AiSidebar.tsx b/apps/web/src/components/ai-sidebar/AiSidebar.tsx index 4412688f..7cd663d3 100644 --- a/apps/web/src/components/ai-sidebar/AiSidebar.tsx +++ b/apps/web/src/components/ai-sidebar/AiSidebar.tsx @@ -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; onCompanionNotesChanged?: () => Promise; onOpenCompanionNote?: (id: string, notebookId: string) => void; }; +const SELECTION_TURN_STORAGE = "edgeever.aiSidebar.selectionTurns"; + +const readSelectionTurnKinds = (): Record => { + 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 = {}; + 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 ( +
+
+ + {kind === "translate" && pinned ? ( + + ) : null} + {kind === "translate" && pinned ? SELECTION_AI_LANGUAGES.map((language) => ( + + )) : null} +
+ {stale ? ( +

{t("aiAssistant.sidebar.selection.stale")}

+ ) : null} +
+ ); +} + 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; + onLaunch: (message: string) => Promise; onStop: () => void; skillPrompt: (id: SkillId, rest?: string) => string; + focusToken: number; }) { const { t } = useTranslation(); const { textInput } = usePromptInputController(); const fileInputRef = useRef(null); + const composerRef = useRef(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 ( -
+
{slashMatches.length ? (
    {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(null); const [useMemory, setUseMemory] = useState(false); + const [composerFocusToken, setComposerFocusToken] = useState(0); + const [selectionTurnKinds, setSelectionTurnKinds] = useState>(readSelectionTurnKinds); const alive = useRef(true); + const handledSelectionRequestId = useRef(null); const locked = useRef(false); const threadPinned = useRef(false); const active = useRef(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 => { 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 ( + 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({
- {selectionCount > 0 ? ( -

- {t("aiAssistant.sidebar.selectionBadge", { count: selectionCount })} -

- ) : null} {error ?

{error}

: null} @@ -1267,6 +1458,7 @@ function AiSidebarSession({ {turn.status === "failed" || turn.status === "cancelled" ? (

{t(`companion.status.${turn.status}`)}

) : null} + {renderSelectionReply(turn.id, turn.response, turn.status)} {renderActions(turn.id)}
@@ -1296,6 +1488,7 @@ function AiSidebarSession({ ) : null} {turn.response ? {turn.response} : null} + {renderSelectionReply(turn.id, turn.response, turn.status)} {turn.status === "failed" || turn.status === "cancelled" ? (

{t(`companion.status.${turn.status}`)}

) : null} @@ -1306,6 +1499,34 @@ function AiSidebarSession({
+ {selectionPin ? ( +
+
+

{selectionPin.displayText || selectionPin.sentText}

+ {selectionPin.truncated ? ( +

+ {t("aiAssistant.sidebar.selection.truncated", { count: Array.from(selectionPin.displayText || selectionPin.sentText).length })} +

+ ) : null} +
+ + + + + + {t("aiAssistant.sidebar.selection.dismiss")} + + +
+ ) : null} {!companionAvailable && source !== "local" ? (

{t("aiAssistant.sidebar.unavailable")}

) : null} @@ -1335,6 +1556,7 @@ function AiSidebarSession({ onLaunch={launch} onStop={() => { void stop(); }} skillPrompt={skillPrompt} + focusToken={composerFocusToken} />
diff --git a/apps/web/src/components/editor/MarkdownSourceEditor.tsx b/apps/web/src/components/editor/MarkdownSourceEditor.tsx index 05785a48..b4efb1b1 100644 --- a/apps/web/src/components/editor/MarkdownSourceEditor.tsx +++ b/apps/web/src/components/editor/MarkdownSourceEditor.tsx @@ -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 = { @@ -122,6 +125,7 @@ export const MarkdownSourceEditor = forwardRef { @@ -129,6 +133,8 @@ export const MarkdownSourceEditor = forwardRef()); + const onSelectionChangeRef = useRef(onSelectionChange); + onSelectionChangeRef.current = onSelectionChange; useImperativeHandle( ref, @@ -170,6 +176,15 @@ export const MarkdownSourceEditor = forwardRef { + 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 { - 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?.(); }), ]; }, []); diff --git a/apps/web/src/components/editor/SelectionAiActions.tsx b/apps/web/src/components/editor/SelectionAiActions.tsx new file mode 100644 index 00000000..6a4307de --- /dev/null +++ b/apps/web/src/components/editor/SelectionAiActions.tsx @@ -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 ( +
event.preventDefault()} + > + + + +
+ ); +} diff --git a/apps/web/src/components/editor/useAiBubbleMenu.test.mjs b/apps/web/src/components/editor/useAiBubbleMenu.test.mjs index 8f67f6f7..c6dd61ae 100644 --- a/apps/web/src/components/editor/useAiBubbleMenu.test.mjs +++ b/apps/web/src/components/editor/useAiBubbleMenu.test.mjs @@ -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); }); diff --git a/apps/web/src/components/editor/useAiBubbleMenu.ts b/apps/web/src/components/editor/useAiBubbleMenu.ts index 95723263..4d5ce185 100644 --- a/apps/web/src/components/editor/useAiBubbleMenu.ts +++ b/apps/web/src/components/editor/useAiBubbleMenu.ts @@ -19,21 +19,20 @@ export const hasAiTextSelection = ({ doc, selection }: Pick 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( ({ editor }) => shouldShowAiBubbleMenu({ - assistantOpen, editable: editor.isEditable, enabled, selectionEmpty: editor.state.selection.empty, selectionHasText: hasAiTextSelection(editor.state), }), - [assistantOpen, enabled], + [enabled], ); return { diff --git a/apps/web/src/desktop-renderer-test.tsx b/apps/web/src/desktop-renderer-test.tsx index e6032c08..69ff27c6 100644 --- a/apps/web/src/desktop-renderer-test.tsx +++ b/apps/web/src/desktop-renderer-test.tsx @@ -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: "

Bubble menu regression

", diff --git a/apps/web/src/lib/selection-ai.test.mjs b/apps/web/src/lib/selection-ai.test.mjs new file mode 100644 index 00000000..e6d1b9a8 --- /dev/null +++ b/apps/web/src/lib/selection-ai.test.mjs @@ -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); + }); +}); diff --git a/apps/web/src/lib/selection-ai.ts b/apps/web/src/lib/selection-ai.ts new file mode 100644 index 00000000..e93154f2 --- /dev/null +++ b/apps/web/src/lib/selection-ai.ts @@ -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"); diff --git a/packages/shared/src/i18n/en-US.ts b/packages/shared/src/i18n/en-US.ts index dea092ba..1b7f4ca6 100644 --- a/packages/shared/src/i18n/en-US.ts +++ b/packages/shared/src/i18n/en-US.ts @@ -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", diff --git a/packages/shared/src/i18n/ja.ts b/packages/shared/src/i18n/ja.ts index cf6b350d..00d9a08c 100644 --- a/packages/shared/src/i18n/ja.ts +++ b/packages/shared/src/i18n/ja.ts @@ -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: "推敲", diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts index d6322bab..204e24f9 100644 --- a/packages/shared/src/i18n/zh-CN.ts +++ b/packages/shared/src/i18n/zh-CN.ts @@ -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: "润色",