feat(agents): Quote a selection from the context menu

Цей коміт міститься в:
l0ng-ai committed 2026-10-28 09:40:44 +08:00
1 джерело 8ba6941016
коміт 1117dc4907
2 files changed
+20 -67

No files matched your search

+1 -19
Переглянути файл
@@ -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;
+19 -48
Переглянути файл
@@ -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 ? (
<Spinner />
@@ -205,7 +218,6 @@ function Stream({ task, entries, finding, findFocus, onCloseFind }: StreamProps)
</div>
)}
</div>
<QuoteSelection task={task} streamRef={ref} />
{!atBottom && (
<IconButton
icon="arrow-down"
@@ -230,52 +242,11 @@ function Elapsed({ since }: { since: number }) {
return <span className={styles.muted}>{formatDuration(now - since)}</span>;
}
/** A Quote button next to text selected in the conversation: replies to it in the composer. */
function QuoteSelection({ task, streamRef }: { task: AgentTask; streamRef: RefObject<HTMLDivElement | null> }) {
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 (
<button
type="button"
className={styles.quoteButton}
style={{ left: at.left, top: at.top }}
// Keep the selection: it is what gets quoted.
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
const text = window.getSelection()?.toString() ?? "";
window.getSelection()?.removeAllRanges();
setAt(null);
if (text.trim()) quoteInComposer(task.id, text);
}}
>
<T>Quote</T>
</button>
);
/** 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<string, unknown> } }).CSS?.highlights;