From 1117dc4907ad434ab6597b5b0b6789d165338f2f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:40:44 +0800 Subject: [PATCH] feat(agents): Quote a selection from the context menu --- src/features/agents/Session.module.css | 20 +------- src/features/agents/SessionEditor.tsx | 67 ++++++++------------------ 2 files changed, 20 insertions(+), 67 deletions(-) diff --git a/src/features/agents/Session.module.css b/src/features/agents/Session.module.css index 69b301e12..db8f63a0d 100644 --- a/src/features/agents/Session.module.css +++ b/src/features/agents/Session.module.css @@ -96,7 +96,7 @@ cursor: default; } -/* The conversation plus what floats over it (find bar, Quote, Jump to Bottom). */ +/* The conversation plus what floats over it (find bar, Jump to Bottom). */ .streamArea { position: relative; flex: 1; @@ -167,24 +167,6 @@ box-shadow: var(--shadow-tooltip); } -.quoteButton { - position: absolute; - z-index: 2; - height: 24px; - padding: 0 10px; - border: 1px solid var(--border); - border-radius: var(--radius-small); - background: var(--bg-popup); - color: var(--fg); - box-shadow: var(--shadow-tooltip); - cursor: pointer; - -webkit-user-select: none; - user-select: none; -} -.quoteButton:hover { - background: var(--bg-hover); -} - .findBar { flex: none; display: flex; diff --git a/src/features/agents/SessionEditor.tsx b/src/features/agents/SessionEditor.tsx index 3d137a1b9..d7fe4cac6 100644 --- a/src/features/agents/SessionEditor.tsx +++ b/src/features/agents/SessionEditor.tsx @@ -1,5 +1,5 @@ /** An agent task as an editor tab: the conversation, a composer and the task's changed files. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type RefObject } from "react"; import clsx from "clsx"; import type { AgentChange, AgentEntry, AgentTask } from "@/bindings"; import { executeAction } from "@/platform/actions"; @@ -15,9 +15,11 @@ import { Spinner, T, Tree, + copyMenuFor, copyText, fileIconName, selectableText, + showContextMenu, } from "@/ui"; import { useTx } from "@/platform/i18n"; import { TaskStatusIcon } from "./AgentsView"; @@ -191,6 +193,17 @@ function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps) stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40; setAtBottom(stick.current); }} + // Copy, plus Quote for a selection: replies to it in the composer. + onContextMenu={(e) => { + const text = selectionIn(e.currentTarget); + const items = copyMenuFor(e.target) ?? []; + if (text.trim() && task.status !== "archived") { + items.push({ id: "quote", label: "Quote", icon: "edit", run: () => quoteInComposer(task.id, text) }); + } + if (!items.length) return; + e.preventDefault(); + showContextMenu(e, items); + }} > {entries === null ? ( @@ -205,7 +218,6 @@ function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps) )} - {!atBottom && ( {formatDuration(now - since)}; } -/** A Quote button next to text selected in the conversation: replies to it in the composer. */ -function QuoteSelection({ task, streamRef }: { task: AgentTask; streamRef: RefObject }) { - const [at, setAt] = useState<{ left: number; top: number } | null>(null); - const place = useCallback(() => { - const stream = streamRef.current; - const sel = window.getSelection(); - if (!stream || !sel || sel.isCollapsed || !sel.rangeCount || !sel.toString().trim()) return setAt(null); - const range = sel.getRangeAt(0); - if (!stream.contains(range.commonAncestorContainer)) return setAt(null); - const rects = range.getClientRects(); - const last = rects[rects.length - 1] ?? range.getBoundingClientRect(); - const area = stream.parentElement!.getBoundingClientRect(); - const box = stream.getBoundingClientRect(); - if (last.bottom < box.top || last.top > box.bottom) return setAt(null); - setAt({ - left: Math.min(Math.max(last.right - area.left, 0), area.width - 80), - top: Math.min(last.bottom - area.top + 4, box.bottom - area.top - 28), - }); - }, [streamRef]); - useEffect(() => { - const stream = streamRef.current; - document.addEventListener("selectionchange", place); - stream?.addEventListener("scroll", place); - return () => { - document.removeEventListener("selectionchange", place); - stream?.removeEventListener("scroll", place); - }; - }, [place, streamRef]); - if (!at || task.status === "archived") return null; - return ( - - ); +/** Text selected in `stream`, or "" when the selection is elsewhere. */ +function selectionIn(stream: HTMLElement): string { + const sel = window.getSelection(); + if (!sel || sel.isCollapsed || !sel.rangeCount) return ""; + return stream.contains(sel.getRangeAt(0).commonAncestorContainer) ? sel.toString() : ""; } const highlights = (globalThis as { CSS?: { highlights?: Map } }).CSS?.highlights;