Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 133 additions & 20 deletions desktop/frontend/src/components/Composer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -332,6 +333,7 @@ export function Composer({
const [sessionRefs, setSessionRefs] = useState<SessionReference[]>([]);
const [loadingPastChats, setLoadingPastChats] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [inputMenuPoint, setInputMenuPoint] = useState<ContextMenuPoint | null>(null);
const taRef = useRef<HTMLTextAreaElement>(null);
const composerCardRef = useRef<HTMLDivElement>(null);
const workspaceAnchorRef = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -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 =>
Expand Down Expand Up @@ -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<HTMLTextAreaElement>) => {
event.preventDefault();
event.stopPropagation();
rememberCaret();
setInputMenuPoint(contextMenuPointFromEvent(event));
};

const pickSession = (session: SessionMeta) => {
setSessionRefs((prev) => {
if (prev.some((x) => x.path === session.path)) {
Expand Down Expand Up @@ -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 (
<div
Expand Down Expand Up @@ -1695,6 +1798,7 @@ export function Composer({
onFocus={rememberCaret}
onPaste={onPaste}
onKeyDown={onKeyDown}
onContextMenu={openInputMenu}
onCompositionStart={() => {
composingRef.current = true;
}}
Expand All @@ -1719,6 +1823,15 @@ export function Composer({
</Tooltip>
)}
</div>
<ContextMenu
open={inputMenuPoint !== null}
point={inputMenuPoint}
items={inputMenuItems}
className="context-menu--composer-input"
minWidth={64}
ariaLabel={t("composer.inputActions")}
onClose={() => setInputMenuPoint(null)}
/>
<div className={composerMetaClass}>
{cwd && (
<div className="composer-meta__control composer-meta__control--workspace composer-workspace-wrap" ref={workspaceAnchorRef}>
Expand Down
4 changes: 3 additions & 1 deletion desktop/frontend/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ export function ContextMenu({
point,
items,
onClose,
className,
minWidth = 180,
ariaLabel = "Context menu",
}: {
open: boolean;
point: ContextMenuPoint | null;
items: ContextMenuItem[];
onClose: () => void;
className?: string;
minWidth?: number;
ariaLabel?: string;
}) {
Expand Down Expand Up @@ -93,7 +95,7 @@ export function ContextMenu({
return createPortal(
<div
ref={menuRef}
className="context-menu"
className={`context-menu${className ? ` ${className}` : ""}`}
role="menu"
aria-label={ariaLabel}
style={{ left: position.left, top: position.top, minWidth }}
Expand Down
4 changes: 4 additions & 0 deletions desktop/frontend/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions desktop/frontend/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const zh: Record<DictKey, string> = {
"common.delete": "删除",
"common.add": "添加",
"common.submit": "提交",
"common.cut": "剪切",
"common.copy": "复制",
"common.paste": "粘贴",
"common.selectAll": "全选",
"common.expand": "展开",
"common.collapse": "收起",
"common.none": "无",
Expand Down Expand Up @@ -227,6 +230,7 @@ export const zh: Record<DictKey, string> = {

// 输入框
"composer.placeholder": "给 Reasonix 发消息… ( / 命令 · @ 文件 )",
"composer.inputActions": "消息输入框操作",
"composer.planMode": "计划模式",
"composer.planModeOn": "计划模式已开",
"composer.planHint": "shift+tab",
Expand Down
6 changes: 6 additions & 0 deletions desktop/frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -9895,6 +9895,12 @@ a[href] {
padding: 0 8px;
}

.context-menu--composer-input .context-menu__item {
justify-content: center;
text-align: center;
padding: 0 6px;
}

.context-menu__item:disabled {
cursor: not-allowed;
opacity: 0.45;
Expand Down
Loading