From 7c0f08c1bbf6e1c2c47d513e0b1fa353fed1ac6b Mon Sep 17 00:00:00 2001 From: Li-Charles-One Date: Tue, 9 Jun 2026 23:15:12 +0800 Subject: [PATCH 1/3] fix(desktop): add composer edit context menu --- desktop/frontend/src/components/Composer.tsx | 152 ++++++++++++++++--- desktop/frontend/src/locales/en.ts | 4 + desktop/frontend/src/locales/zh.ts | 4 + 3 files changed, 140 insertions(+), 20 deletions(-) diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index b0f8bbe09..c55c86d63 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react"; +import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react"; import { AlertTriangle, ArrowUp, Check, ChevronDown, Eye, FileText, Folder, FolderGit2, FolderPlus, List, MessageSquare, Search, Square, Trash2, X, Zap } from "lucide-react"; import { asArray } from "../lib/array"; import { DedupIndex, sha256 } from "../lib/attachDedup"; @@ -20,6 +20,7 @@ import { EffortSwitcher } from "./EffortSwitcher"; import { ModelSwitcher } from "./ModelSwitcher"; import { Tooltip } from "./Tooltip"; import { AnchoredPopover } from "./AnchoredPopover"; +import { ContextMenu, contextMenuPointFromEvent, type ContextMenuItem, type ContextMenuPoint } from "./ContextMenu"; interface Attachment { path: string; @@ -332,6 +333,7 @@ export function Composer({ const [sessionRefs, setSessionRefs] = useState([]); const [loadingPastChats, setLoadingPastChats] = useState(false); const [submitting, setSubmitting] = useState(false); + const [inputMenuPoint, setInputMenuPoint] = useState(null); const taRef = useRef(null); const composerCardRef = useRef(null); const workspaceAnchorRef = useRef(null); @@ -792,25 +794,7 @@ export function Composer({ if (!shouldFoldPaste(pasted)) return; e.preventDefault(); - const ta = e.currentTarget; - const start = ta.selectionStart ?? text.length; - const end = ta.selectionEnd ?? text.length; - const id = nextPasteId.current++; - const lines = lineCount(pasted); - const label = t("composer.pastedLabel", { id, lines }); - const block: PastedBlock = { label, text: pasted }; - const next = text.slice(0, start) + label + text.slice(end); - - pastedBlocksRef.current = [...pastedBlocksRef.current, block]; - setPastedBlocks((prev) => [...prev, block]); - setText(next); - requestAnimationFrame(() => { - const node = taRef.current; - if (!node) return; - const pos = start + label.length; - node.focus(); - node.selectionStart = node.selectionEnd = pos; - }); + insertPastedText(pasted, e.currentTarget.selectionStart ?? text.length, e.currentTarget.selectionEnd ?? text.length); }; const hasWorkspaceReferenceDrag = (dataTransfer: DataTransfer): boolean => @@ -1139,6 +1123,96 @@ export function Composer({ return value.replace(/(?:^|\s)@[^\s]*$/, "").trimEnd(); }; + const getInputSelection = () => { + const node = taRef.current; + const start = node?.selectionStart ?? text.length; + const end = node?.selectionEnd ?? text.length; + const from = Math.min(start, end); + const to = Math.max(start, end); + return { + from, + to, + selected: text.slice(from, to), + }; + }; + + const focusInputRange = (start: number, end = start) => { + requestAnimationFrame(() => { + const node = taRef.current; + if (!node) return; + node.focus(); + node.setSelectionRange(start, end); + lastSelectionRef.current = { start, end }; + }); + }; + + const replaceInputRange = (value: string, start: number, end: number) => { + const next = text.slice(0, start) + value + text.slice(end); + setText(next); + focusInputRange(start + value.length); + }; + + const insertPastedText = (pasted: string, start: number, end: number) => { + if (!shouldFoldPaste(pasted)) { + replaceInputRange(pasted, start, end); + return; + } + + const id = nextPasteId.current++; + const lines = lineCount(pasted); + const label = t("composer.pastedLabel", { id, lines }); + const block: PastedBlock = { label, text: pasted }; + const next = text.slice(0, start) + label + text.slice(end); + + pastedBlocksRef.current = [...pastedBlocksRef.current, block]; + setPastedBlocks((prev) => [...prev, block]); + setText(next); + focusInputRange(start + label.length); + }; + + const copyComposerSelection = async (cut = false) => { + const selection = getInputSelection(); + setInputMenuPoint(null); + if (!selection.selected || !navigator.clipboard?.writeText) { + focusInputRange(selection.from, selection.to); + return; + } + try { + await navigator.clipboard.writeText(selection.selected); + if (cut) replaceInputRange("", selection.from, selection.to); + else focusInputRange(selection.from, selection.to); + } catch { + focusInputRange(selection.from, selection.to); + } + }; + + const pasteIntoComposer = async () => { + const selection = getInputSelection(); + setInputMenuPoint(null); + if (!navigator.clipboard?.readText) { + focusInputRange(selection.from, selection.to); + return; + } + try { + const pasted = await navigator.clipboard.readText(); + insertPastedText(pasted, selection.from, selection.to); + } catch { + focusInputRange(selection.from, selection.to); + } + }; + + const selectAllComposerText = () => { + setInputMenuPoint(null); + focusInputRange(0, text.length); + }; + + const openInputMenu = (event: ReactMouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + rememberCaret(); + setInputMenuPoint(contextMenuPointFromEvent(event)); + }; + const pickSession = (session: SessionMeta) => { setSessionRefs((prev) => { if (prev.some((x) => x.path === session.path)) { @@ -1314,6 +1388,35 @@ export function Composer({ hasWorkspace ? "composer-meta--has-workspace" : "composer-meta--no-workspace", hasEffort ? "composer-meta--has-effort" : "composer-meta--no-effort", ].join(" "); + const inputSelection = getInputSelection(); + const hasInputSelection = inputSelection.from !== inputSelection.to; + const inputMenuItems: ContextMenuItem[] = [ + { + key: "cut", + label: t("common.cut"), + disabled: disabled || !hasInputSelection, + onSelect: () => void copyComposerSelection(true), + }, + { + key: "copy", + label: t("common.copy"), + disabled: !hasInputSelection, + onSelect: () => void copyComposerSelection(), + }, + { + key: "paste", + label: t("common.paste"), + disabled, + onSelect: () => void pasteIntoComposer(), + }, + { type: "separator", key: "edit-separator" }, + { + key: "select-all", + label: t("common.selectAll"), + disabled: text.length === 0, + onSelect: selectAllComposerText, + }, + ]; return (
{ composingRef.current = true; }} @@ -1719,6 +1823,14 @@ export function Composer({ )}
+ setInputMenuPoint(null)} + />
{cwd && (
diff --git a/desktop/frontend/src/locales/en.ts b/desktop/frontend/src/locales/en.ts index b899e2e6b..b5598c6e3 100644 --- a/desktop/frontend/src/locales/en.ts +++ b/desktop/frontend/src/locales/en.ts @@ -12,7 +12,10 @@ export const en = { "common.delete": "Delete", "common.add": "Add", "common.submit": "Submit", + "common.cut": "Cut", "common.copy": "Copy", + "common.paste": "Paste", + "common.selectAll": "Select all", "common.expand": "Expand", "common.collapse": "Collapse", "common.none": "none", @@ -226,6 +229,7 @@ export const en = { // composer "composer.placeholder": "Message Reasonix… ( / commands · @ files )", + "composer.inputActions": "Message input actions", "composer.planMode": "plan mode", "composer.planModeOn": "plan mode on", "composer.planHint": "shift+tab", diff --git a/desktop/frontend/src/locales/zh.ts b/desktop/frontend/src/locales/zh.ts index ff212e778..efb02d926 100644 --- a/desktop/frontend/src/locales/zh.ts +++ b/desktop/frontend/src/locales/zh.ts @@ -13,7 +13,10 @@ export const zh: Record = { "common.delete": "删除", "common.add": "添加", "common.submit": "提交", + "common.cut": "剪切", "common.copy": "复制", + "common.paste": "粘贴", + "common.selectAll": "全选", "common.expand": "展开", "common.collapse": "收起", "common.none": "无", @@ -227,6 +230,7 @@ export const zh: Record = { // 输入框 "composer.placeholder": "给 Reasonix 发消息… ( / 命令 · @ 文件 )", + "composer.inputActions": "消息输入框操作", "composer.planMode": "计划模式", "composer.planModeOn": "计划模式已开", "composer.planHint": "shift+tab", From 1ee50406876533472caf5a5f9362d46cdf2019f1 Mon Sep 17 00:00:00 2001 From: Li-Charles-One Date: Tue, 9 Jun 2026 23:28:00 +0800 Subject: [PATCH 2/3] fix(desktop): narrow composer context menu --- desktop/frontend/src/components/Composer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index c55c86d63..bb5f9c0b3 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -1827,7 +1827,7 @@ export function Composer({ open={inputMenuPoint !== null} point={inputMenuPoint} items={inputMenuItems} - minWidth={150} + minWidth={112} ariaLabel={t("composer.inputActions")} onClose={() => setInputMenuPoint(null)} /> From 6050f5c7d21edfedc63450711fba10272aa26f8a Mon Sep 17 00:00:00 2001 From: Li-Charles-One Date: Tue, 9 Jun 2026 23:31:21 +0800 Subject: [PATCH 3/3] fix(desktop): compact composer context menu --- desktop/frontend/src/components/Composer.tsx | 3 ++- desktop/frontend/src/components/ContextMenu.tsx | 4 +++- desktop/frontend/src/styles.css | 6 ++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/desktop/frontend/src/components/Composer.tsx b/desktop/frontend/src/components/Composer.tsx index bb5f9c0b3..62d136a5d 100644 --- a/desktop/frontend/src/components/Composer.tsx +++ b/desktop/frontend/src/components/Composer.tsx @@ -1827,7 +1827,8 @@ export function Composer({ open={inputMenuPoint !== null} point={inputMenuPoint} items={inputMenuItems} - minWidth={112} + className="context-menu--composer-input" + minWidth={64} ariaLabel={t("composer.inputActions")} onClose={() => setInputMenuPoint(null)} /> diff --git a/desktop/frontend/src/components/ContextMenu.tsx b/desktop/frontend/src/components/ContextMenu.tsx index cb7102f5e..a5a3c150e 100644 --- a/desktop/frontend/src/components/ContextMenu.tsx +++ b/desktop/frontend/src/components/ContextMenu.tsx @@ -44,6 +44,7 @@ export function ContextMenu({ point, items, onClose, + className, minWidth = 180, ariaLabel = "Context menu", }: { @@ -51,6 +52,7 @@ export function ContextMenu({ point: ContextMenuPoint | null; items: ContextMenuItem[]; onClose: () => void; + className?: string; minWidth?: number; ariaLabel?: string; }) { @@ -93,7 +95,7 @@ export function ContextMenu({ return createPortal(