Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 27 additions & 4 deletions apps/desktop/renderer/src/turns/TurnComposer.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
124 changes: 124 additions & 0 deletions apps/desktop/renderer/test/turn-composer-shortcuts.test.tsx
Original file line number Diff line number Diff line change
@@ -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<TurnComposerProps> & { initial?: string }) {
const [value, onChange] = useState(initial);
return <TurnComposer value={value} onChange={onChange} placeholder="Task" disabledReason={null}
running={false} canCancel={false} onSend={vi.fn()} onCancel={vi.fn()} {...overrides} />;
}
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(<Composer onSend={send} />);
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(<Composer onSend={send} />);
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(<Composer initial={"a\nb"} />);
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(<Composer running onSend={send} />);
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(<Composer sendShortcut="mod-enter" onSend={send} />);
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(<Composer sendShortcut="enter" onSend={send} />);
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(<Composer initial="你好" sendShortcut={sendShortcut} onSend={send} />);
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(<Composer sendShortcut={sendShortcut} onSend={send} {...state} />);
enter(input(), sendShortcut === "enter" ? {} : { ctrlKey: true });
expect(send).not.toHaveBeenCalled();
});
});
});
53 changes: 53 additions & 0 deletions apps/desktop/renderer/test/turn-progress-interaction.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TurnThread turns={[received]} />);
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(<TurnThread turns={[turn()]} />);
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(<TurnThread turns={[turn({ status: "completed", completedAt: ended, output: "检查完成。",
progress: [...turn().progress!, { kind: "completed", at: ended }] })]} />);
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"));
Expand Down Expand Up @@ -110,6 +138,8 @@ describe("conversation progress disclosure", () => {
const onVerdict = vi.fn();
const { rerender } = render(<TurnThread turns={[pending]} onVerdict={onVerdict} />);
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");
Expand All @@ -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(<TurnThread turns={[turn()]} />);
expect(document.querySelector(".owb-turn-progress__spinner")).not.toBeNull();
rerender(<TurnThread turns={[turn({ status, completedAt: ended, error: "执行未完成",
progress: [...turn().progress!, { kind, at: ended }] })]} />);
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(<TurnThread turns={[turn({ status: "indeterminate", completedAt: ended, error: "连接中断" })]} />);
expect(disclosure()).toHaveAttribute("aria-expanded", "false");
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export const enCatalog: Record<string, string> = {
"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",
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ export const zhCatalog: Record<string, string> = {
"project.welcomeDescription": "与 AI 员工一起完成项目。",
"project.welcomeAction": "打开或新建项目",
"turn.pickEmployeeHint": "从左侧选择一位员工",
"turn.keyboardHint": "Enter 发送 · Shift + Enter 换行",
"turn.keyboardHint": "Enter 发送 · Ctrl/⌘ + Enter 或 Shift + Enter 换行",
// 模块导轨
"rail.org": "组织",
"rail.groups": "群聊",
Expand Down