From 5d5fbbad07aea25e1d805d54bdd1a8db6a1b617e Mon Sep 17 00:00:00 2001 From: Wind Li Date: Sun, 2 Aug 2026 11:46:44 +0800 Subject: [PATCH 1/5] feat: quote-reply popover on assistant messages --- .gitignore | 3 +- components/ChatWindow.tsx | 6 ++ components/MarkdownBody.tsx | 169 ++++++++++++++++++++++++++++--- components/MessageView.tsx | 17 ++-- components/QuoteReplyPopover.tsx | 107 +++++++++++++++++++ lib/i18n/messages/en.ts | 2 + lib/i18n/messages/zh-CN.ts | 2 + lib/quote-reply.ts | 136 +++++++++++++++++++++++++ 8 files changed, 422 insertions(+), 20 deletions(-) create mode 100644 components/QuoteReplyPopover.tsx create mode 100644 lib/quote-reply.ts diff --git a/.gitignore b/.gitignore index 3a44c749f..28cabfba0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts -.factory \ No newline at end of file +.factory + diff --git a/components/ChatWindow.tsx b/components/ChatWindow.tsx index 794a34cf3..4eaff4003 100644 --- a/components/ChatWindow.tsx +++ b/components/ChatWindow.tsx @@ -196,6 +196,11 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate chatInputRef?.current?.insertIfEmpty(content); }, [chatInputRef]); + /** Insert a quoted reply into the input box (user decides how to send it). */ + const handleQuoteReply = useCallback((quote: string) => { + chatInputRef?.current?.prependText(quote); + }, [chatInputRef]); + const { loading, error, messages, entryIds, streamState, agentRunning, bashRunning, pendingBash, modelNames, modelList, modelError, modelScopeWarnings, modelThinkingLevels, modelThinkingLevelMaps, toolPreset, thinkingLevel, @@ -584,6 +589,7 @@ export function ChatWindow({ session, newSessionCwd, onAgentEnd, onSessionCreate onNavigate={sessionBusy ? undefined : handleNavigate} prevAssistantEntryId={sessionBusy ? undefined : prevAssistantEntryId} onEditContent={handleEditContent} + onQuoteReply={handleQuoteReply} showTimestamp={showTimestamp} prevTimestamp={idx > 0 ? (messages[idx - 1] as AgentMessage & { timestamp?: number }).timestamp : undefined} sessionId={session?.id ?? sessionIdRef.current ?? undefined} diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index cc6bd6822..d049e1b67 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -1,11 +1,14 @@ "use client"; -import { useMemo, type MouseEvent } from "react"; +import { createContext, useContext, useEffect, useId, useMemo, useRef, useState, type MouseEvent, type ReactNode } from "react"; import ReactMarkdown, { type Components } from "react-markdown"; import { resolveLocalFileHref } from "@/lib/file-links"; import { encodeFilePathForApi } from "@/lib/file-paths"; import { markdownRehypePlugins, markdownRemarkPlugins, normalizeDisplayMath } from "@/lib/markdown"; import { MermaidBlock, CodeBlock } from "./MermaidBlock"; +import { QuoteReplyPopover } from "./QuoteReplyPopover"; +import { useI18n } from "@/hooks/useI18n"; +import { parseParagraph, type ParsedSegment } from "@/lib/quote-reply"; interface MarkdownBodyProps { children: string; @@ -13,10 +16,20 @@ interface MarkdownBodyProps { isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; + /** When set (assistant messages), each paragraph becomes hoverable/clickable + * to pop a quote-reply popover. */ + onQuoteReply?: (quote: string) => void; } -export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile }: MarkdownBodyProps) { +/** Exactly one quote-reply popover can be open at a time (per message body). */ +const QuoteOpenContext = createContext<{ openId: string | null; setOpenId: (id: string | null) => void }>({ + openId: null, + setOpenId: () => {}, +}); + +export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile, onQuoteReply }: MarkdownBodyProps) { const normalizedMarkdown = useMemo(() => normalizeDisplayMath(children), [children]); + const [openId, setOpenId] = useState(null); // Stable renderer identities keep stateful blocks mounted across message hover updates. const components = useMemo(() => ({ code({ className, children, ...props }) { @@ -79,6 +92,26 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile // eslint-disable-next-line @next/next/no-img-element return {alt; }, + p({ children, ...props }) { + delete props.node; + const pid = useId(); + if (!onQuoteReply) return

{children}

; + return ( + + {children} + + ); + }, + li({ children, ...props }) { + delete props.node; + const pid = useId(); + if (!onQuoteReply) return
  • {children}
  • ; + return ( + + {children} + + ); + }, table({ children }) { return (
    @@ -86,17 +119,129 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile
    ); }, - }), [cwd, isStreaming, onOpenFile]); + }), [cwd, isStreaming, onOpenFile, onQuoteReply]); return ( -
    - - {normalizedMarkdown} - -
    + +
    + + {normalizedMarkdown} + +
    +
    + ); +} + +/** A

    /

  • whose plain text is parsed on hover (desktop) / click (mobile) + * to pop a quote-reply popover. The parse result is locked once shown so a + * streaming tail doesn't make the popover flicker; re-engaging re-parses. */ +function QuoteableParagraph({ children, onQuoteReply, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; as?: "p" | "li"; pid: string }) { + const { openId, setOpenId } = useContext(QuoteOpenContext); + const { t } = useI18n(); + const open = openId === pid; + const ref = useRef(null); + const [segments, setSegments] = useState(null); + const [showTip, setShowTip] = useState(false); + const tipRef = useRef(null); + // Detect touch capability lazily (same approach as useIsMobile but local). + const [coarse] = useState(() => typeof window !== "undefined" && window.matchMedia?.("(pointer: coarse)").matches); + + // Another paragraph opened its popover → close ours (only one popover at a time). + useEffect(() => { + if (!open && segments) setSegments(null); + }, [open, segments]); + + // When the popover opens, ensure it's in view (the paragraph near the + // bottom of the viewport would otherwise push it out of sight). + const popoverRef = useRef(null); + useEffect(() => { + if (segments && popoverRef.current) { + popoverRef.current.scrollIntoView({ block: "nearest" }); + } + }, [segments]); + + const openPopover = () => { + if (segments || open) return; + const text = ref.current?.textContent ?? ""; + // Any paragraph is quoteable (not just questions): closed questions get + // option buttons, everything else gets a fallback quote button. + const parsed = parseParagraph(text); + if (parsed.length > 0) { + setSegments(parsed); + setOpenId(pid); + } + }; + const closePopover = () => { + setSegments(null); + setOpenId(null); + }; + // Click toggles: show on first click, hide on the second. + const toggle = () => { + if (segments) closePopover(); + else openPopover(); + }; + + const Tag = as as React.ElementType; + // Follow-the-mouse tooltip: position updated imperatively on mousemove (no + // re-render per move); mouseenter sets it via rAF so it shows even if the + // pointer doesn't move afterwards. + const moveTip = (x: number, y: number) => { + if (tipRef.current) { + tipRef.current.style.left = `${x + 12}px`; + tipRef.current.style.top = `${y + 14}px`; + } + }; + const showTooltip = (e: MouseEvent) => { + if (coarse) return; + setShowTip(true); + const { clientX, clientY } = e; + requestAnimationFrame(() => moveTip(clientX, clientY)); + }; + const hideTooltip = () => { + setShowTip(false); + }; + return ( + ) => moveTip(e.clientX, e.clientY)} + onMouseLeave={hideTooltip} + onClick={toggle} + style={{ position: "relative", cursor: "pointer" }} + > + {children} + {showTip && !segments && ( + + {t("chat.quoteReplyHint")} + + )} + {segments && ( + { onQuoteReply(q); closePopover(); }} + /> + )} + ); } diff --git a/components/MessageView.tsx b/components/MessageView.tsx index d73bee37f..6b6018f6f 100644 --- a/components/MessageView.tsx +++ b/components/MessageView.tsx @@ -54,6 +54,7 @@ function loadThinkingContent(sessionId: string, entryId: string, blockIndex: num } interface Props { + onQuoteReply?: (quote: string) => void; message: AgentMessage; isStreaming?: boolean; toolResults?: Map; @@ -98,12 +99,12 @@ function haveSameRelevantToolResults( return true; } -export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { +export const MessageView = memo(function MessageView({ message, isStreaming, toolResults, modelNames, cwd, onOpenFile, onQuoteReply, entryId, onFork, forking, onNavigate, prevAssistantEntryId, onEditContent, showTimestamp, prevTimestamp, sessionId }: Props) { if (message.role === "user") { return ; } if (message.role === "assistant") { - return ; + return ; } if (message.role === "toolResult") { // Rendered inline under its toolCall — skip standalone rendering if paired @@ -345,6 +346,7 @@ function AssistantMessageView({ modelNames, cwd, onOpenFile, + onQuoteReply, showTimestamp, prevTimestamp, sessionId, @@ -356,6 +358,7 @@ function AssistantMessageView({ modelNames?: Record; cwd?: string; onOpenFile?: (filePath: string) => void; + onQuoteReply?: (quote: string) => void; showTimestamp?: boolean; prevTimestamp?: number; sessionId?: string; @@ -529,7 +532,7 @@ function AssistantMessageView({
    {blockItems.map(({ block, originalIndex }) => ( - + ))}
    @@ -603,9 +606,9 @@ function AssistantMessageView({ ); } -function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { +function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCallDurations, cwd, onOpenFile, onQuoteReply, sessionId, entryId, blockIndex }: { block: AssistantContentBlock; toolResults?: Map; isStreaming?: boolean; streamingDuration?: number; toolCallDurations?: Map; cwd?: string; onOpenFile?: (filePath: string) => void; onQuoteReply?: (quote: string) => void; sessionId?: string; entryId?: string; blockIndex: number }) { if (block.type === "text") { - return ; + return ; } if (block.type === "thinking") { return ; @@ -619,8 +622,8 @@ function BlockView({ block, toolResults, isStreaming, streamingDuration, toolCal return null; } -function TextBlock({ block, isStreaming, cwd, onOpenFile }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void }) { - return {block.text}; +function TextBlock({ block, isStreaming, cwd, onOpenFile, onQuoteReply }: { block: TextContent; isStreaming?: boolean; cwd?: string; onOpenFile?: (filePath: string) => void; onQuoteReply?: (quote: string) => void }) { + return {block.text}; } function ThinkingBlock({ block, duration, sessionId, entryId, blockIndex }: { diff --git a/components/QuoteReplyPopover.tsx b/components/QuoteReplyPopover.tsx new file mode 100644 index 000000000..14be11eaf --- /dev/null +++ b/components/QuoteReplyPopover.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useI18n } from "@/hooks/useI18n"; +import type { Ref } from "react"; +import type { ParsedSegment, QuoteOption } from "@/lib/quote-reply"; +import { formatQuote } from "@/lib/quote-reply"; + +interface Props { + segments: ParsedSegment[]; + /** Called with the formatted quote-reply text (caller inserts it into the input). */ + onPick: (quote: string) => void; + /** Optional ref to the popover element (caller scrolls it into view on open). */ + innerRef?: Ref; +} + +/** + * A button row for one paragraph's parsed questions. Each question gets its + * own sub-row: detected options (是/否, A/B, …) when available, otherwise a + * single fallback "quote" button. Clicking inserts a quoted reply into the + * input box — never sends. + */ +export function QuoteReplyPopover({ segments, onPick, innerRef }: Props) { + const { t } = useI18n(); + // Show every segment: closed questions get option buttons, the rest get a + // fallback quote button. (Any paragraph is quoteable.) + const questions = segments; + if (questions.length === 0) return null; + + return ( + e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > + {questions.map((seg, i) => ( + + ))} + + ); +} + +function SegmentRow({ + segment, + onPick, + t, +}: { + segment: ParsedSegment; + onPick: (quote: string) => void; + t: (k: string) => string; +}) { + const options: QuoteOption[] = + segment.options ?? [{ label: t("chat.quoteReply"), value: "" }]; + return ( + + {options.map((opt, j) => ( + + ))} + + ); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 156cb961c..2f0244008 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -217,6 +217,8 @@ export const enLocale: LocalePlugin = { "chat.compactContext": "Compact context", "chat.compacting": "Compacting…", "chat.compact": "Compact", + "chat.quoteReply": "Quote reply", + "chat.quoteReplyHint": "Click to quote reply", "chat.stopAgent": "Stop agent", "chat.stop": "Stop", "chat.disableSound": "Disable completion sound", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index 7854f8142..b5ebf4494 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -217,6 +217,8 @@ export const zhCNLocale: LocalePlugin = { "chat.compactContext": "压缩上下文", "chat.compacting": "正在压缩…", "chat.compact": "压缩", + "chat.quoteReply": "引用回复", + "chat.quoteReplyHint": "点击引用回复", "chat.stopAgent": "停止 Agent", "chat.stop": "停止", "chat.disableSound": "关闭完成提示音", diff --git a/lib/quote-reply.ts b/lib/quote-reply.ts new file mode 100644 index 000000000..98990e656 --- /dev/null +++ b/lib/quote-reply.ts @@ -0,0 +1,136 @@ +/** + * Quote-reply helpers: detect questions/options in an assistant message and + * format a quoted reply (markdown blockquote + optional pre-filled answer) + * for insertion into the input box. The user then decides how to send it + * (prompt / steer / followUp) — these helpers never send. + */ + +export interface QuoteOption { + /** Short label for the button. */ + label: string; + /** Pre-filled answer line under the quote. */ + value: string; +} + +/** Strip light markdown emphasis so matching works on plain text. */ +function clean(text: string): string { + return text.replace(/[*_`~]/g, "").trim(); +} + +/** Truncate to `n` chars with an ellipsis. */ +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n).trimEnd() + "…" : s; +} + +/** Trim filler words from an option fragment pulled out of a sentence. */ +function cleanOption(s: string): string { + return s + .replace(/^(我要|我想我?|你想要?|你想我?|你想要?|想要?|需要|你来|你来?|用|使用|选|选择|我|你)\s*/u, "") + .replace(/[吗吧呢]?$/u, "") + .replace(/[??,,。.!!、]$/gu, "") + .trim(); +} + +/** + * Split a paragraph into question/segment chunks by terminal punctuation and + * choice connectors. Returns [] for non-question paragraphs (caller decides + * whether to still offer a plain quote). + */ +export function splitQuestions(text: string): string[] { + const t = clean(text); + if (!t) return []; + // Split AFTER ?/? and on ;; — but NOT before 还是/或者, so that an + // "A 还是 B" choice stays whole for detectOptions to match as one segment. + const parts = t + .split(/(?<=[??])|[;;]/u) + .map((s) => s.trim()) + .filter((s) => s.length > 0); + return parts; +} + +/** Whether a segment looks like a question at all (used to decide engagement). */ +export function isQuestion(seg: string): boolean { + return /[??]\s*$|吗[??]?\s*$|是否|要不要|能不能|可不可以|还是|或者/u.test(seg); +} + +/** + * Detect concrete options in a question segment. + * Returns null when no clear options can be extracted (caller falls back to a + * plain quote with an empty answer line). + */ +export function detectOptions(seg: string): QuoteOption[] | null { + const t = clean(seg); + if (!t) return null; + + // 1) Explicit choice: "A 还是 B" / "A 或者 B" / "A or B" (\bor\b so + // "store"/"word" don't split at their internal "or"). + const choice = t.match(/^(.+?)\s*(?:还是|或者|或者还是|\bor\b)\s*(.+?)[??]?\s*$/iu); + if (choice) { + const a = cleanOption(choice[1]); + const b = cleanOption(choice[2]); + if (a && b && a !== b) { + return [ + { label: truncate(a, 12), value: a }, + { label: truncate(b, 12), value: b }, + ]; + } + } + + // 2) Yes/no question (Chinese cues + trailing ?). But NOT open-ended + // questions (怎么做/哪个/什么/…) — those have no yes/no answer, so fall + // through to the plain-quote fallback. + const openEnded = /怎么|如何|怎样|哪个|哪些|什么|为什么|为何|谁|多少|几(个|点|时)?|what|how|why|who|where/u.test(t); + const yesNo = !openEnded && (/[??]\s*$/u.test(t) || /(?:吗|吧|呢)[??]?\s*$/u.test(t) || /是否|要不要|能不能|可不可以|要不要我|需要我/u.test(t)); + if (yesNo) { + // Try to surface the action ("要我X吗" → "好,X") for a more concrete button. + const action = t + .replace(/^(要我|需要我|要不要我?|是否|能不能|可不可以|要不要|或者|还是|你能|你可以|请|麻烦)\s*/u, "") + .replace(/[吗吧呢啊呀]?[??]+$/u, "") // trailing 吗?/? + .replace(/[吗吧呢啊呀]$/u, "") // bare trailing 吗/吧/呢 + .trim(); + if (action && action.length <= 16) { + return [ + { label: `好,${truncate(action, 10)}`, value: "是的" }, + { label: "不用", value: "不用了" }, + ]; + } + return [ + { label: "是", value: "是的" }, + { label: "否", value: "不用了" }, + ]; + } + + // 3) Not a recognizable closed question. + return null; +} + +/** + * Format a quoted reply. Each source line is prefixed with "> "; an optional + * pre-filled answer line follows (empty line if none) so the user types under + * the quote, email-reply style. + */ +export function formatQuote(seg: string, value?: string): string { + const quote = clean(seg) + .split("\n") + .map((l) => `> ${l}`) + .join("\n"); + return value ? `${quote}\n${value}` : `${quote}\n`; +} + +export interface ParsedSegment { + /** The cleaned segment text. */ + text: string; + /** Detected options, or null when unclear (fallback to plain quote). */ + options: QuoteOption[] | null; +} + +/** + * Parse a whole paragraph into per-segment results (used by the popover to + * list each question with its own button row). + */ +export function parseParagraph(text: string): ParsedSegment[] { + return splitQuestions(text).map((seg) => ({ + text: seg, + options: detectOptions(seg), + })); +} From 267ba5935225d87c0e628f5b61272bceb2221fa7 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Sun, 2 Aug 2026 15:00:23 +0800 Subject: [PATCH 2/5] feat: quote-reply on table rows (cells joined with |) --- components/MarkdownBody.tsx | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index d049e1b67..a26a561f6 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -119,6 +119,16 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile ); }, + tr({ children, ...props }) { + delete props.node; + const pid = useId(); + if (!onQuoteReply) return {children}; + return ( + + {children} + + ); + }, }), [cwd, isStreaming, onOpenFile, onQuoteReply]); return ( @@ -139,7 +149,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile /** A

    /

  • whose plain text is parsed on hover (desktop) / click (mobile) * to pop a quote-reply popover. The parse result is locked once shown so a * streaming tail doesn't make the popover flicker; re-engaging re-parses. */ -function QuoteableParagraph({ children, onQuoteReply, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; as?: "p" | "li"; pid: string }) { +function QuoteableParagraph({ children, onQuoteReply, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; as?: "p" | "li" | "tr"; pid: string }) { const { openId, setOpenId } = useContext(QuoteOpenContext); const { t } = useI18n(); const open = openId === pid; @@ -166,7 +176,12 @@ function QuoteableParagraph({ children, onQuoteReply, as = "p", pid }: { childre const openPopover = () => { if (segments || open) return; - const text = ref.current?.textContent ?? ""; + // For table rows, join cell text with " | " so the quoted line reads like + // a markdown row instead of all cells mashed together. + const el = ref.current; + const text = as === "tr" && el + ? Array.from(el.querySelectorAll("td, th")).map((c) => (c.textContent ?? "").trim()).join(" | ") + : (el?.textContent ?? ""); // Any paragraph is quoteable (not just questions): closed questions get // option buttons, everything else gets a fallback quote button. const parsed = parseParagraph(text); From 6a86a78d204640f4693c2cfd7ce02aa36b03b992 Mon Sep 17 00:00:00 2001 From: Wind Li Date: Sun, 2 Aug 2026 16:47:33 +0800 Subject: [PATCH 3/5] feat: open inline-mentioned files from quote-reply popover --- components/MarkdownBody.tsx | 12 +++--- components/QuoteReplyPopover.tsx | 74 +++++++++++++++++++++++++++++++- lib/i18n/messages/en.ts | 1 + lib/i18n/messages/zh-CN.ts | 1 + lib/quote-reply.ts | 24 +++++++++++ 5 files changed, 105 insertions(+), 7 deletions(-) diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index a26a561f6..024cb78e9 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -15,7 +15,7 @@ interface MarkdownBodyProps { className?: string; isStreaming?: boolean; cwd?: string; - onOpenFile?: (filePath: string) => void; + onOpenFile?: (filePath: string, fileName?: string) => void; /** When set (assistant messages), each paragraph becomes hoverable/clickable * to pop a quote-reply popover. */ onQuoteReply?: (quote: string) => void; @@ -97,7 +97,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile const pid = useId(); if (!onQuoteReply) return

    {children}

    ; return ( - + {children} ); @@ -107,7 +107,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile const pid = useId(); if (!onQuoteReply) return
  • {children}
  • ; return ( - + {children} ); @@ -124,7 +124,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile const pid = useId(); if (!onQuoteReply) return {children}; return ( - + {children} ); @@ -149,7 +149,7 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile /** A

    /

  • whose plain text is parsed on hover (desktop) / click (mobile) * to pop a quote-reply popover. The parse result is locked once shown so a * streaming tail doesn't make the popover flicker; re-engaging re-parses. */ -function QuoteableParagraph({ children, onQuoteReply, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; as?: "p" | "li" | "tr"; pid: string }) { +function QuoteableParagraph({ children, onQuoteReply, onOpenFile, cwd, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; onOpenFile?: (filePath: string, fileName?: string) => void; cwd?: string; as?: "p" | "li" | "tr"; pid: string }) { const { openId, setOpenId } = useContext(QuoteOpenContext); const { t } = useI18n(); const open = openId === pid; @@ -255,6 +255,8 @@ function QuoteableParagraph({ children, onQuoteReply, as = "p", pid }: { childre innerRef={popoverRef} segments={segments} onPick={(q) => { onQuoteReply(q); closePopover(); }} + onOpenFile={onOpenFile} + cwd={cwd} /> )} diff --git a/components/QuoteReplyPopover.tsx b/components/QuoteReplyPopover.tsx index 14be11eaf..bf3d5734d 100644 --- a/components/QuoteReplyPopover.tsx +++ b/components/QuoteReplyPopover.tsx @@ -1,14 +1,20 @@ "use client"; +import { useEffect, useState } from "react"; import { useI18n } from "@/hooks/useI18n"; import type { Ref } from "react"; +import { encodeFilePathForApi, joinFilePath } from "@/lib/file-paths"; import type { ParsedSegment, QuoteOption } from "@/lib/quote-reply"; -import { formatQuote } from "@/lib/quote-reply"; +import { extractFilePaths, formatQuote } from "@/lib/quote-reply"; interface Props { segments: ParsedSegment[]; /** Called with the formatted quote-reply text (caller inserts it into the input). */ onPick: (quote: string) => void; + /** Open a file path (from inline paths mentioned in the text). */ + onOpenFile?: (filePath: string, fileName?: string) => void; + /** Session cwd used to resolve relative paths before checking /api/files. */ + cwd?: string; /** Optional ref to the popover element (caller scrolls it into view on open). */ innerRef?: Ref; } @@ -19,13 +25,38 @@ interface Props { * single fallback "quote" button. Clicking inserts a quoted reply into the * input box — never sends. */ -export function QuoteReplyPopover({ segments, onPick, innerRef }: Props) { +export function QuoteReplyPopover({ segments, onPick, onOpenFile, cwd, innerRef }: Props) { const { t } = useI18n(); // Show every segment: closed questions get option buttons, the rest get a // fallback quote button. (Any paragraph is quoteable.) const questions = segments; if (questions.length === 0) return null; + // Inline file paths mentioned in the text (assistant often lists files as + // plain text, not links). Verify each against the backend before offering + // an "open" action so we don't render dead buttons. + const [existingFiles, setExistingFiles] = useState([]); + useEffect(() => { + let cancelled = false; + const allPaths = Array.from(new Set(questions.flatMap((seg) => extractFilePaths(seg.text)))); + const absPaths = allPaths.map((p) => + p.startsWith("/") ? p : (cwd ? joinFilePath(cwd, p) : p), + ); + Promise.all( + absPaths.map(async (abs) => { + try { + const res = await fetch(`/api/files/${encodeFilePathForApi(abs)}?type=meta`); + return res.ok ? abs : null; + } catch { + return null; + } + }), + ).then((found) => { + if (!cancelled) setExistingFiles(found.filter((f): f is string => !!f)); + }); + return () => { cancelled = true; }; + }, [questions, cwd]); + return ( e.preventDefault()} onClick={(e) => e.stopPropagation()} > + {onOpenFile && existingFiles.length > 0 && ( + + {existingFiles.map((abs) => ( + + ))} + + )} {questions.map((seg, i) => ( ))} @@ -105,3 +167,11 @@ function SegmentRow({ ); } + +function FolderOpenIcon() { + return ( + + ); +} diff --git a/lib/i18n/messages/en.ts b/lib/i18n/messages/en.ts index 2f0244008..057379d56 100644 --- a/lib/i18n/messages/en.ts +++ b/lib/i18n/messages/en.ts @@ -219,6 +219,7 @@ export const enLocale: LocalePlugin = { "chat.compact": "Compact", "chat.quoteReply": "Quote reply", "chat.quoteReplyHint": "Click to quote reply", + "i18n.openFile": "Open", "chat.stopAgent": "Stop agent", "chat.stop": "Stop", "chat.disableSound": "Disable completion sound", diff --git a/lib/i18n/messages/zh-CN.ts b/lib/i18n/messages/zh-CN.ts index b5ebf4494..5b92d5a17 100644 --- a/lib/i18n/messages/zh-CN.ts +++ b/lib/i18n/messages/zh-CN.ts @@ -219,6 +219,7 @@ export const zhCNLocale: LocalePlugin = { "chat.compact": "压缩", "chat.quoteReply": "引用回复", "chat.quoteReplyHint": "点击引用回复", + "i18n.openFile": "打开", "chat.stopAgent": "停止 Agent", "chat.stop": "停止", "chat.disableSound": "关闭完成提示音", diff --git a/lib/quote-reply.ts b/lib/quote-reply.ts index 98990e656..06e3ed8b8 100644 --- a/lib/quote-reply.ts +++ b/lib/quote-reply.ts @@ -117,6 +117,30 @@ export function formatQuote(seg: string, value?: string): string { return value ? `${quote}\n${value}` : `${quote}\n`; } +/** + * Extract candidate file paths from plain text (e.g. "design/foo.md" written + * inline by the assistant, not as a markdown link). Returns de-duplicated + * paths without trailing punctuation. The caller should verify existence via + * the backend before offering them as "open" actions. + */ +export function extractFilePaths(text: string): string[] { + // 1) Paths containing a slash with an extension: dir/name.ext, ./dir/name.ext + // 2) Bare filenames with a common source/doc extension + const re = /(?:\.[\w-]+\/|[\w-]+\/)+[\w.@-]+\.\w+|\b[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|mdx|json|py|go|rs|css|scss|html|sh|yml|yaml|toml|lock|txt|sql|env)\b/gu; + const seen = new Set(); + const out: string[] = []; + for (const m of text.matchAll(re)) { + let p = m[0]; + // Strip trailing punctuation that the regex might have swallowed. + p = p.replace(/[),。、;:!!??)>"'`]$/u, ""); + if (p.length >= 3 && !seen.has(p)) { + seen.add(p); + out.push(p); + } + } + return out; +} + export interface ParsedSegment { /** The cleaned segment text. */ text: string; From 8072c85264e09024ebc1b3172cd6c16bf8e500df Mon Sep 17 00:00:00 2001 From: Wind Li Date: Sun, 2 Aug 2026 22:09:30 +0800 Subject: [PATCH 4/5] fix: exclude tooltip text from quote-reply extraction # Conflicts: # components/MarkdownBody.tsx --- components/MarkdownBody.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/MarkdownBody.tsx b/components/MarkdownBody.tsx index 024cb78e9..218220f4b 100644 --- a/components/MarkdownBody.tsx +++ b/components/MarkdownBody.tsx @@ -149,6 +149,15 @@ export function MarkdownBody({ children, className, isStreaming, cwd, onOpenFile /** A

    /

  • whose plain text is parsed on hover (desktop) / click (mobile) * to pop a quote-reply popover. The parse result is locked once shown so a * streaming tail doesn't make the popover flicker; re-engaging re-parses. */ +/** Plain text of a quoteable element, excluding transient UI children (the + * follow-mouse tooltip) so the quoted reply isn't polluted. */ +function getQuoteText(el: HTMLElement | null): string { + if (!el) return ""; + const clone = el.cloneNode(true) as HTMLElement; + clone.querySelectorAll("[data-quote-tip]").forEach((n) => n.remove()); + return clone.textContent ?? ""; +} + function QuoteableParagraph({ children, onQuoteReply, onOpenFile, cwd, as = "p", pid }: { children: ReactNode; onQuoteReply: (quote: string) => void; onOpenFile?: (filePath: string, fileName?: string) => void; cwd?: string; as?: "p" | "li" | "tr"; pid: string }) { const { openId, setOpenId } = useContext(QuoteOpenContext); const { t } = useI18n(); @@ -181,7 +190,7 @@ function QuoteableParagraph({ children, onQuoteReply, onOpenFile, cwd, as = "p", const el = ref.current; const text = as === "tr" && el ? Array.from(el.querySelectorAll("td, th")).map((c) => (c.textContent ?? "").trim()).join(" | ") - : (el?.textContent ?? ""); + : getQuoteText(el); // Any paragraph is quoteable (not just questions): closed questions get // option buttons, everything else gets a fallback quote button. const parsed = parseParagraph(text); @@ -232,6 +241,7 @@ function QuoteableParagraph({ children, onQuoteReply, onOpenFile, cwd, as = "p", {showTip && !segments && ( Date: Thu, 6 Aug 2026 07:02:19 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix(quote-reply):=20=E5=BC=95=E7=94=A8?= =?UTF-8?q?=E5=8E=9F=E6=A0=B7=E4=BF=9D=E7=95=99=E6=B8=B2=E6=9F=93=E6=96=87?= =?UTF-8?q?=E6=9C=AC=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=89=A5=E7=A6=BB=E5=AD=97?= =?UTF-8?q?=E7=AC=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit clean() 假定输入是 markdown 源码、按强调符剥离 [*_`~],但实际输入是 react-markdown 渲染后的 DOM textContent(语法字符已被渲染器消费), 剩下的都是字面字符——环境变量名 PI_WEB_BASE_DOMAIN 的下划线被误删成 PIWEBBASEDOMAIN。直接删除 clean() 及其三处调用(splitQuestions / detectOptions / formatQuote),引用回复插入输入框的内容与消息渲染 结果逐字一致。新增 lib/quote-reply.test.mjs 覆盖回归场景。 --- lib/quote-reply.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ lib/quote-reply.ts | 18 +++++++++--------- 2 files changed, 45 insertions(+), 9 deletions(-) create mode 100644 lib/quote-reply.test.mjs diff --git a/lib/quote-reply.test.mjs b/lib/quote-reply.test.mjs new file mode 100644 index 000000000..795b26a31 --- /dev/null +++ b/lib/quote-reply.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { detectOptions, formatQuote, parseParagraph, splitQuestions } from "./quote-reply.ts"; + +test("quoted replies preserve underscores in identifiers and env var names", () => { + // Regression: clean() used to strip `_` as if it were markdown emphasis, + // mangling PI_WEB_BASE_DOMAIN → PIWEBBASEDOMAIN in quoted replies. + assert.equal(formatQuote("命名你定:PI_WEB_BASE_DOMAIN"), "> 命名你定:PI_WEB_BASE_DOMAIN\n"); + const [seg] = splitQuestions("用 PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.match(seg, /PI_WEB_BASE_DOMAIN/); + assert.match(seg, /PIWEBPROXYSUBFIX/); +}); + +test("splitQuestions keeps an A-还是-B choice whole with underscores intact", () => { + const parts = splitQuestions("命名你定:PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.deepEqual(parts, ["命名你定:PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"]); +}); + +test("formatQuote keeps the rendered text verbatim (no markdown processing)", () => { + // The input is already-rendered text — literal chars survive quoting. + assert.equal(formatQuote("用 *斜体* 强调 `code` ~~删除~~"), "> 用 *斜体* 强调 `code` ~~删除~~\n"); +}); + +test("detectOptions still recognizes A-还是-B choices and keeps underscores", () => { + const options = detectOptions("命名你定:PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.ok(options, "expected a choice to be detected"); + assert.equal(options.length, 2); + assert.ok(options.some((o) => o.value.includes("PI_WEB_BASE_DOMAIN"))); + assert.ok(options.some((o) => o.value.includes("PIWEBPROXYSUBFIX"))); +}); + +test("parseParagraph yields options + full quoted segment for env-var choices", () => { + const [seg] = parseParagraph("用 PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.equal(seg.text, "用 PI_WEB_BASE_DOMAIN 还是 PIWEBPROXYSUBFIX?"); + assert.ok(seg.options?.length === 2); +}); diff --git a/lib/quote-reply.ts b/lib/quote-reply.ts index 06e3ed8b8..2df904476 100644 --- a/lib/quote-reply.ts +++ b/lib/quote-reply.ts @@ -3,6 +3,11 @@ * format a quoted reply (markdown blockquote + optional pre-filled answer) * for insertion into the input box. The user then decides how to send it * (prompt / steer / followUp) — these helpers never send. + * + * Input is the RENDERED paragraph text (DOM textContent): markdown syntax has + * already been consumed by the renderer, so the text is used verbatim — no + * character stripping. Literal chars (underscores in PI_WEB_BASE_DOMAIN, `*` + * inside code, …) must survive quoting intact. */ export interface QuoteOption { @@ -12,11 +17,6 @@ export interface QuoteOption { value: string; } -/** Strip light markdown emphasis so matching works on plain text. */ -function clean(text: string): string { - return text.replace(/[*_`~]/g, "").trim(); -} - /** Truncate to `n` chars with an ellipsis. */ function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n).trimEnd() + "…" : s; @@ -37,7 +37,7 @@ function cleanOption(s: string): string { * whether to still offer a plain quote). */ export function splitQuestions(text: string): string[] { - const t = clean(text); + const t = text.trim(); if (!t) return []; // Split AFTER ?/? and on ;; — but NOT before 还是/或者, so that an // "A 还是 B" choice stays whole for detectOptions to match as one segment. @@ -59,7 +59,7 @@ export function isQuestion(seg: string): boolean { * plain quote with an empty answer line). */ export function detectOptions(seg: string): QuoteOption[] | null { - const t = clean(seg); + const t = seg.trim(); if (!t) return null; // 1) Explicit choice: "A 还是 B" / "A 或者 B" / "A or B" (\bor\b so @@ -110,7 +110,7 @@ export function detectOptions(seg: string): QuoteOption[] | null { * the quote, email-reply style. */ export function formatQuote(seg: string, value?: string): string { - const quote = clean(seg) + const quote = seg .split("\n") .map((l) => `> ${l}`) .join("\n"); @@ -142,7 +142,7 @@ export function extractFilePaths(text: string): string[] { } export interface ParsedSegment { - /** The cleaned segment text. */ + /** The rendered segment text (verbatim, no stripping). */ text: string; /** Detected options, or null when unclear (fallback to plain quote). */ options: QuoteOption[] | null;