From 8986a553acb9a95ba677f8a702288ac1dcab8f87 Mon Sep 17 00:00:00 2001 From: PeterGuy326 <47820304+PeterGuy326@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:07:16 +0800 Subject: [PATCH] fix(conversation): preserve shortcuts and IME-safe multiline drafting --- CHANGELOG.md | 2 + .../renderer/src/turns/TurnComposer.tsx | 31 ++++- .../test/turn-composer-shortcuts.test.tsx | 124 ++++++++++++++++++ .../test/turn-progress-interaction.test.tsx | 53 ++++++++ packages/ui/src/locales/en.ts | 2 +- packages/ui/src/locales/zh.ts | 2 +- 6 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/renderer/test/turn-composer-shortcuts.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f73145f..bee31a5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### Fixed +- 对话默认使用 Enter 发送时,Ctrl/Command + Enter 可在光标处插入换行或替换选中文字,并保留正确光标位置;保留 Shift + Enter 换行、输入法确认保护和设置中的发送快捷键选择。 + - #339 Android 原生客户端:release 强制 HTTPS;debug 才允许明文以便连本机 RoleWeave。WebSocket 断开按 1/2/4/8/16s 指数退避重连最多 5 次。发指令必须在组织页显式点选岗位,不再默认第一角色。`android-client.yml` 对 `mobile/android` 跑 `gradle test`(不安装已下线的 SDK `tools` 包)。 ### Added diff --git a/apps/desktop/renderer/src/turns/TurnComposer.tsx b/apps/desktop/renderer/src/turns/TurnComposer.tsx index a6e7b629..6176974b 100644 --- a/apps/desktop/renderer/src/turns/TurnComposer.tsx +++ b/apps/desktop/renderer/src/turns/TurnComposer.tsx @@ -1,4 +1,4 @@ -import type { FormEvent, ReactNode } from "react"; +import { useLayoutEffect, useRef, type FormEvent, type ReactNode } from "react"; import { Button as AntButton, Input } from "antd"; import { ArrowUp, Square } from "lucide-react"; import { useConversationCopy } from "../locales/conversation"; @@ -47,6 +47,15 @@ export function TurnComposer({ }: TurnComposerProps) { const t = useT(); const copy = useConversationCopy(); + const composing = useRef(false); + const pendingCaret = useRef<{ input: HTMLTextAreaElement; value: string; offset: number } | null>(null); + useLayoutEffect(() => { + const pending = pendingCaret.current; + pendingCaret.current = null; + if (pending && pending.input.isConnected && pending.input.value === pending.value) { + pending.input.setSelectionRange(pending.offset, pending.offset); + } + }); const submit = (event: FormEvent) => { event.preventDefault(); if (!running && !disabledReason && value.trim()) void onSend(); @@ -63,11 +72,25 @@ export function TurnComposer({ placeholder={placeholder} disabled={draftDisabled ?? (disabledReason !== null && !running)} onChange={(event) => onChange(event.target.value)} + onCompositionStart={() => { composing.current = true; }} + onCompositionEnd={() => { composing.current = false; }} onKeyDown={(event) => { - // Enter sends; Shift+Enter keeps multiline input. During Chinese - // IME composition Enter only commits the selected candidate. + // Preserve the configured send shortcut and native Shift+Enter. + // IME confirmation must neither send nor insert an extra newline. const native = event.nativeEvent as KeyboardEvent; - if (native.isComposing || native.keyCode === 229) return; + if (composing.current || native.isComposing || native.keyCode === 229) return; + if (event.key === "Enter" && sendShortcut === "enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + const input = event.currentTarget; + const start = input.selectionStart; + const nextValue = `${value.slice(0, start)}\n${value.slice(input.selectionEnd)}`; + // Restore the caret after the controlled value commits, on this + // textarea only; switching conversations must not move another caret. + if (nextValue === value) input.setSelectionRange(start + 1, start + 1); + else pendingCaret.current = { input, value: nextValue, offset: start + 1 }; + onChange(nextValue); + return; + } if (event.key === "Enter" && !event.shiftKey && (sendShortcut === "enter" ? !event.metaKey && !event.ctrlKey : event.metaKey || event.ctrlKey)) { event.preventDefault(); if (!running && !disabledReason && value.trim()) void onSend(); diff --git a/apps/desktop/renderer/test/turn-composer-shortcuts.test.tsx b/apps/desktop/renderer/test/turn-composer-shortcuts.test.tsx new file mode 100644 index 00000000..113d4eb9 --- /dev/null +++ b/apps/desktop/renderer/test/turn-composer-shortcuts.test.tsx @@ -0,0 +1,124 @@ +import { useState } from "react"; +import { act, createEvent, fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { TurnComposer, type TurnComposerProps } from "../src/turns/TurnComposer"; + +function Composer({ initial = "before AFTER", ...overrides }: Partial & { initial?: string }) { + const [value, onChange] = useState(initial); + return ; +} +function input() { + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + act(() => textarea.focus()); + return textarea; +} +function enter(textarea: HTMLTextAreaElement, modifiers: KeyboardEventInit = {}) { + const event = createEvent.keyDown(textarea, { key: "Enter", ...modifiers }); + fireEvent(textarea, event); + return event; +} +const shortcuts = ["enter", "mod-enter"] as const; +const modifiers = [{ ctrlKey: true }, { metaKey: true }]; + +describe("conversation send shortcuts", () => { + it("sends on Enter by default and leaves Shift+Enter to native multiline editing", () => { + const send = vi.fn(); + render(); + const textarea = input(); + expect(enter(textarea, { shiftKey: true }).defaultPrevented).toBe(false); + expect(send).not.toHaveBeenCalled(); + expect(enter(textarea).defaultPrevented).toBe(true); + expect(send).toHaveBeenCalledExactlyOnceWith(); + expect(screen.getByText(/Ctrl\/⌘ \+ Enter/)).toBeVisible(); + }); + + describe.each(modifiers)("newline with %j in Enter mode", modifier => { + it.each([ + [0, 0, "\nbefore AFTER", 1], + [6, 6, "before\n AFTER", 7], + [12, 12, "before AFTER\n", 13], + [7, 12, "before \n", 8], + ] as const)("replaces selection %s..%s and restores the caret", (start, end, expected, caret) => { + const send = vi.fn(); + render(); + const textarea = input(); + textarea.setSelectionRange(start, end); + expect(enter(textarea, modifier).defaultPrevented).toBe(true); + expect(textarea).toHaveValue(expected); + expect(textarea.selectionStart).toBe(caret); + expect(textarea.selectionEnd).toBe(caret); + expect(send).not.toHaveBeenCalled(); + // A second keystroke must use the restored caret, not append to the end. + enter(textarea, modifier); + expect(textarea).toHaveValue(`${expected.slice(0, caret)}\n${expected.slice(caret)}`); + expect(textarea.selectionStart).toBe(caret + 1); + }); + + it("collapses a selected newline even when the controlled value is unchanged", () => { + render(); + const textarea = input(); + textarea.setSelectionRange(1, 2); + enter(textarea, modifier); + expect(textarea).toHaveValue("a\nb"); + expect(textarea.selectionStart).toBe(2); + expect(textarea.selectionEnd).toBe(2); + }); + + it("allows multiline drafting while a task runs without dispatching another task", () => { + const send = vi.fn(); + render(); + const textarea = input(); + textarea.setSelectionRange(12, 12); + enter(textarea, modifier); + expect(textarea).toHaveValue("before AFTER\n"); + enter(textarea); + expect(send).not.toHaveBeenCalled(); + }); + }); + + it.each(modifiers)("preserves configured modifier-Enter sending with %j", modifier => { + const send = vi.fn(); + const { rerender } = render(); + const textarea = input(); + expect(enter(textarea).defaultPrevented).toBe(false); + expect(enter(textarea, { ...modifier, shiftKey: true }).defaultPrevented).toBe(false); + expect(send).not.toHaveBeenCalled(); + expect(enter(textarea, modifier).defaultPrevented).toBe(true); + expect(send).toHaveBeenCalledTimes(1); + // Changing the preference immediately changes behavior on the same draft. + rerender(); + textarea.setSelectionRange(12, 12); + enter(textarea, modifier); + expect(textarea).toHaveValue("before AFTER\n"); + expect(send).toHaveBeenCalledTimes(1); + }); + + describe.each(shortcuts)("IME safety with %s", sendShortcut => { + it.each([{}, ...modifiers])("ignores composing Enter with %j", modifier => { + const send = vi.fn(); + render(); + const textarea = input(); + expect(enter(textarea, { ...modifier, isComposing: true }).defaultPrevented).toBe(false); + expect(enter(textarea, { ...modifier, keyCode: 229 }).defaultPrevented).toBe(false); + fireEvent.compositionStart(textarea); + expect(enter(textarea, modifier).defaultPrevented).toBe(false); + fireEvent.compositionEnd(textarea); + expect(textarea).toHaveValue("你好"); + expect(send).not.toHaveBeenCalled(); + enter(textarea, sendShortcut === "enter" ? {} : { ctrlKey: true }); + expect(send).toHaveBeenCalledTimes(1); + }); + + it.each([ + { initial: " \n " }, + { running: true }, + { disabledReason: "Unavailable", draftDisabled: false }, + ])("keeps the send gate for %j", state => { + const send = vi.fn(); + render(); + enter(input(), sendShortcut === "enter" ? {} : { ctrlKey: true }); + expect(send).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/desktop/renderer/test/turn-progress-interaction.test.tsx b/apps/desktop/renderer/test/turn-progress-interaction.test.tsx index 9a3cc30f..51f65323 100644 --- a/apps/desktop/renderer/test/turn-progress-interaction.test.tsx +++ b/apps/desktop/renderer/test/turn-progress-interaction.test.tsx @@ -20,6 +20,34 @@ function disclosure() { afterEach(() => vi.useRealTimers()); describe("conversation progress disclosure", () => { + it("moves the animated milestone from acceptance to processing and stops it on completion", () => { + const received = turn({ output: undefined, progress: [{ kind: "received", at: started }] }); + const { rerender } = render(); + const acceptedStep = screen.getByText("任务已接收").closest("li"); + expect(acceptedStep).toHaveAttribute("aria-current", "step"); + expect(acceptedStep?.querySelector(".owb-turn-progress__spinner")).not.toBeNull(); + expect(screen.queryByText("处理请求")).not.toBeInTheDocument(); + + rerender(); + const workingStep = screen.getByText("处理请求").closest("li"); + expect(screen.getByText("任务已接收").closest("li")).not.toHaveAttribute("aria-current"); + expect(screen.getByText("任务已接收").closest("li")?.querySelector(".owb-turn-progress__spinner")).toBeNull(); + expect(workingStep).toHaveAttribute("aria-current", "step"); + expect(workingStep?.querySelector(".owb-turn-progress__spinner")).not.toBeNull(); + expect(document.querySelectorAll(".owb-turn-progress__spinner")).toHaveLength(1); + + rerender(); + expect(disclosure()).toHaveTextContent("已完成"); + expect(document.querySelector('[aria-current="step"]')).toBeNull(); + expect(document.querySelector(".owb-turn-progress__spinner")).toBeNull(); + fireEvent.click(disclosure()); + expect(screen.getByText("任务已接收")).toBeVisible(); + expect(screen.getByText("处理请求")).toBeVisible(); + expect(screen.getByText("回合已完成")).toBeVisible(); + expect(screen.getByRole("region", { name: "最终结论" })).toHaveTextContent("检查完成。"); + }); + it("opens a live run, shows its elapsed time, and marks only the current milestone", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-09-10T06:00:08.000Z")); @@ -110,6 +138,8 @@ describe("conversation progress disclosure", () => { const onVerdict = vi.fn(); const { rerender } = render(); expect(disclosure()).toHaveTextContent("等待审批"); + expect(document.querySelector('[aria-current="step"]')).toBeNull(); + expect(document.querySelector(".owb-turn-progress__spinner")).toBeNull(); expect(screen.getByRole("button", { name: "批准并继续" })).toBeVisible(); fireEvent.click(screen.getByRole("button", { name: "批准并继续" })); expect(onVerdict).toHaveBeenCalledWith(pending, "granted"); @@ -118,6 +148,29 @@ describe("conversation progress disclosure", () => { expect(screen.queryByRole("button", { name: "批准并继续" })).not.toBeInTheDocument(); }); + it.each([ + { status: "failed" as const, kind: "failed" as const, summary: "失败" }, + { status: "indeterminate" as const, kind: "unknown" as const, summary: "状态未知" }, + ])("stops processing animation and its clock when a run becomes $status", ({ status, kind, summary }) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-10T06:00:08.000Z")); + const { rerender } = render(); + expect(document.querySelector(".owb-turn-progress__spinner")).not.toBeNull(); + rerender(); + expect(disclosure()).toHaveTextContent(summary); + expect(disclosure()).toHaveAttribute("aria-expanded", "false"); + expect(document.querySelector('[aria-current="step"]')).toBeNull(); + expect(document.querySelector(".owb-turn-progress__spinner")).toBeNull(); + expect(screen.getByRole("timer")).toHaveTextContent("12s"); + expect(vi.getTimerCount()).toBe(0); + expect(screen.queryByRole("region", { name: "最终结论" })).not.toBeInTheDocument(); + expect(screen.getByText("执行未完成")).toBeVisible(); + fireEvent.click(disclosure()); + expect(screen.getByText("处理请求")).toBeVisible(); + expect(document.querySelector(".owb-turn-progress__spinner")).toBeNull(); + }); + it("never presents interrupted output as a final conclusion or hides the warning in the disclosure", () => { render(); expect(disclosure()).toHaveAttribute("aria-expanded", "false"); diff --git a/packages/ui/src/locales/en.ts b/packages/ui/src/locales/en.ts index 92e87684..dca66396 100644 --- a/packages/ui/src/locales/en.ts +++ b/packages/ui/src/locales/en.ts @@ -117,7 +117,7 @@ export const enCatalog: Record = { "project.welcomeDescription": "Work on your project with AI employees.", "project.welcomeAction": "Open or create a project", "turn.pickEmployeeHint": "Choose an employee in the sidebar", - "turn.keyboardHint": "Enter to send · Shift + Enter for a new line", + "turn.keyboardHint": "Enter to send · Ctrl/⌘ + Enter or Shift + Enter for a new line", // Module rail "rail.org": "Organization", "rail.groups": "Groups", diff --git a/packages/ui/src/locales/zh.ts b/packages/ui/src/locales/zh.ts index 89a77221..486eb9d6 100644 --- a/packages/ui/src/locales/zh.ts +++ b/packages/ui/src/locales/zh.ts @@ -120,7 +120,7 @@ export const zhCatalog: Record = { "project.welcomeDescription": "与 AI 员工一起完成项目。", "project.welcomeAction": "打开或新建项目", "turn.pickEmployeeHint": "从左侧选择一位员工", - "turn.keyboardHint": "Enter 发送 · Shift + Enter 换行", + "turn.keyboardHint": "Enter 发送 · Ctrl/⌘ + Enter 或 Shift + Enter 换行", // 模块导轨 "rail.org": "组织", "rail.groups": "群聊",