feat(ui): 会话输入框发送键位可配置

0.3.2 把 Ctrl+Enter 发送改成了 Enter 发送,习惯旧键位的用户没有退路。
系统设置新增「会话输入框发送键位」,两种模式二选一:

- Enter 发送,Shift+Enter 换行(默认,即 0.3.2 现行为)
- Ctrl+Enter 发送,Enter 换行(0.3.2 之前的键位)

实现:

- 新增 lib/chat-send-mode.ts,复用已有的 local-storage.client 读写封装;
  纯前端偏好,不入库、不随账号同步
- 对话页与任务详情主 Agent 会话原本各自内联了一份相同判断,统一收敛到
  shouldSubmitOnKey(),加模式时不必两处各改一遍
- enter 模式保留原判断(只排除 Shift),没顺手把 Ctrl+Enter 也变成换行——
  原本它是会发送的,改掉会让没动设置的用户感到意外
- 中文输入法保护照旧:isComposing / keyCode 229 时放行,回车选词不误发送
- useSyncExternalStore + 自维护订阅集合:storage 事件只在其他标签页触发,
  本页改完需手动 emit 才能立即生效;getServerSnapshot 给默认值避免 SSR 不一致
- ctrl-enter 模式同时接受 Ctrl 与 Cmd(macOS)

按需求放在系统设置页,未采用 issue 中建议的输入框内控件。

Closes #39

Claude-Session: https://claude.ai/code/session_01Q8nhYZfS4H8fsSCBJXxbSy
This commit is contained in:
Autumn-27 committed 2026-08-22 07:18:21 -04:00
1 parent ca7300ad52
commit 5126ac4592
4 files changed
+113 -5

No files matched your search

+4 -2
View File
@@ -47,6 +47,7 @@ import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { api } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import type { Activity, Agent, ChatAttachment, Conversation, LLMProfile } from "@/lib/types";
import { cn } from "@/lib/utils";
@@ -128,9 +129,10 @@ function Composer({
}) {
const fileInputRef = React.useRef<HTMLInputElement>(null);
const atts = attachments ?? [];
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
function onKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
// Enter 发送;Shift+Enter 换行。isComposing 时是中文等输入法在选字,不拦截。
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
onSend();
}
@@ -44,6 +44,7 @@ import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupTextarea } fro
import { ScrollArea } from "@/components/ui/scroll-area";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { api, sseUrl } from "@/lib/api";
import { shouldSubmitOnKey, useChatSendMode } from "@/lib/chat-send-mode";
import { MOCK } from "@/lib/mock/enabled";
import type {
Activity,
@@ -1239,6 +1240,8 @@ export function SessionsTab({ taskId }: { taskId: string }) {
}
const mainLoaded = !!store.main?.loaded;
// 发送键位由系统设置决定(localStorage),默认 Enter 发送。
const sendMode = useChatSendMode();
// What the transcript pane should show for the active session.
const showLoader = !activeState || (activeState.loading && !activeState.loaded);
const resolutionForSession = (session: Session): TaskLLMResolution | undefined => {
@@ -1511,8 +1514,7 @@ export function SessionsTab({ taskId }: { taskId: string }) {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key !== "Enter" || e.shiftKey || e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229)
return;
if (!shouldSubmitOnKey(e, sendMode)) return;
e.preventDefault();
if (!mainLive) send();
}}
+40 -1
View File
@@ -2,7 +2,7 @@
import * as React from "react";
import { CpuIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
import { CpuIcon, KeyboardIcon, RadioTowerIcon, SearchIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
@@ -12,6 +12,12 @@ import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { api } from "@/lib/api";
import {
CHAT_SEND_MODE_OPTIONS,
type ChatSendMode,
setChatSendMode,
useChatSendMode,
} from "@/lib/chat-send-mode";
import type { Settings } from "@/lib/types";
export default function SystemSettingsPage() {
@@ -32,6 +38,8 @@ export default function SystemSettingsPage() {
const [pyInterp, setPyInterp] = React.useState("");
const [workers, setWorkers] = React.useState("3");
const [savingWorkers, setSavingWorkers] = React.useState(false);
// 纯前端偏好:不走 /api/settings,直接读写 localStorage。
const sendMode = useChatSendMode();
const apply = React.useCallback((s: Settings) => {
setTrafficCapture(!!s.traffic_capture);
@@ -424,6 +432,37 @@ export default function SystemSettingsPage() {
</div>
</CardContent>
</Card>
<Card className="max-w-2xl">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyboardIcon className="size-4" />
会话输入框发送键位
</CardTitle>
<CardDescription>
对话页与任务详情的主 Agent 会话输入框共用此设置,选择后立即生效、无需保存。
<br />
该偏好<b>只存在本浏览器</b>,不随账号同步,换浏览器或清理站点数据后需重新设置。
</CardDescription>
</CardHeader>
<CardContent className="flex items-center justify-between gap-4">
<Label htmlFor="chat-send-mode" className="text-sm font-normal text-muted-foreground">
发送方式
</Label>
<Select value={sendMode} onValueChange={(v) => setChatSendMode(v as ChatSendMode)}>
<SelectTrigger id="chat-send-mode" className="w-72">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CHAT_SEND_MODE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</CardContent>
</Card>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
"use client";
import * as React from "react";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
// 会话输入框的发送/换行键位。纯前端偏好:只落 localStorage,不入库、不随账号同步,
// 因此换浏览器需要重设。见 issue #39——0.3.2 把 Ctrl+Enter 发送改成了 Enter 发送,
// 这里把旧键位还回来作为可选项。
export type ChatSendMode = "enter" | "ctrl-enter";
export const CHAT_SEND_MODE_KEY = "artex_chat_send_mode";
export const DEFAULT_CHAT_SEND_MODE: ChatSendMode = "enter";
export const CHAT_SEND_MODE_OPTIONS: { value: ChatSendMode; label: string }[] = [
{ value: "enter", label: "Enter 发送,Shift+Enter 换行" },
{ value: "ctrl-enter", label: "Ctrl+Enter 发送,Enter 换行" },
];
function parseMode(raw: string | null): ChatSendMode {
return raw === "ctrl-enter" || raw === "enter" ? raw : DEFAULT_CHAT_SEND_MODE;
}
// 同一标签页内的订阅者集合。localStorage 的 storage 事件只在「其他」标签页触发,
// 本页在设置里改完后要靠 emit 通知同页的输入框,否则得刷新才生效。
const listeners = new Set<() => void>();
function subscribe(listener: () => void) {
listeners.add(listener);
window.addEventListener("storage", listener);
return () => {
listeners.delete(listener);
window.removeEventListener("storage", listener);
};
}
// 返回的是字符串字面量,Object.is 按值比较,不会让 useSyncExternalStore 陷入循环。
function getSnapshot(): ChatSendMode {
return parseMode(getLocalStorageValue(CHAT_SEND_MODE_KEY));
}
// 服务端没有 localStorage,先渲染默认值,hydrate 后 getSnapshot 再纠正。
function getServerSnapshot(): ChatSendMode {
return DEFAULT_CHAT_SEND_MODE;
}
export function useChatSendMode(): ChatSendMode {
return React.useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
export function setChatSendMode(mode: ChatSendMode) {
setLocalStorageValue(CHAT_SEND_MODE_KEY, mode);
for (const listener of listeners) listener();
}
// shouldSubmitOnKey 判断一次按键是否应当发送。
// isComposing / keyCode 229 是中文等输入法正在选字,必须放行,否则回车选词会误发送。
// enter 模式只排除 Shift,与 0.3.2 的行为逐字保持一致——不改设置的用户手感不变。
// ctrl-enter 模式同时接受 Ctrl 与 Cmd(macOS)。
export function shouldSubmitOnKey(e: React.KeyboardEvent, mode: ChatSendMode): boolean {
if (e.key !== "Enter") return false;
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return false;
if (mode === "ctrl-enter") return e.ctrlKey || e.metaKey;
return !e.shiftKey;
}