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
7 changes: 7 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ paths = ['''(^|/)packages/(browser-extension/test/protocol\.test\.ts|webui/test/
regexTarget = "secret"
regexes = ['''^00112233445566778899aabbccddeeff$''']

[[rules.allowlists]]
description = "WebUI shortcut storage key name, not a credential"
condition = "AND"
paths = ['''(^|/)packages/webui/src/client/projection/common/shortcuts\.ts$''']
regexTarget = "match"
regexes = ['''^SHORTCUT_STORAGE_KEY = "webui-shortcuts\.v1"$''']

[[rules.allowlists]]
description = "Documented keyboard shortcut"
condition = "AND"
Expand Down
5 changes: 4 additions & 1 deletion packages/webui/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ is the sole decision record for Mini App hosting. ADR 0021 records the model pic
with two states, and the cascade it replaces. ADR 0024 records the client-side workspace boundary
for a diff body: a diff entry outside the workspace keeps its name and its counts and loses its
content, the boundary is lexical, and a diff whose workspace root the client does not know is not
withheld. A new decision takes the next free number.
withheld. ADR 0026 records the composer's keyboard bindings as the settings page stores them: one
canonical notation, the settings page owns the change, the composer reads the stored binding when
a key is pressed, and only the actions that already have keyboard meaning are bindable. A new
decision takes the next free number.

| Retired | Was | Why it is not in the log | Where the live facts are |
| --- | --- | --- | --- |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# The composer follows the bindings the settings page stores

**Status:** Accepted and implemented.
**Date:** 2026-10-26.

The settings page's **快捷键** tab was a label with no panel behind it: `shortcuts` was the one
preference entry declared `disabled: true` next to 语音 and 连接, and the composer decided what
Enter meant in code — `resolveWebuiComposerEnterAction` answered "submit" or "newline" from the
modifier flags alone. A user could not see that rule, could not change it, and a page that lists
actions it cannot bind is a page that lies.

The settings page now **lists the composer's keyboard actions, shows the key each one is bound to,
and changes the binding**. Two actions are bindable, because two actions have keyboard meaning
today:

| Action | Built-in binding |
| --- | --- |
| 发送消息 — send the message | `Enter` |
| 换行 — break the line | `Shift+Enter` |

## What a binding is

A binding is **one canonical string**: the modifiers in a fixed order, then the base key —
`Control+Shift+Enter`. The same string is what the browser stores, what the page shows and what the
composer matches against, so a binding cannot mean one thing on the page and another in the
composer. The notation, the matching, the conflict rule and the fallback to the built-in bindings
live in one module (`client/projection/common/shortcuts.ts`) that both sides read, because both sides
have to agree on what a binding is.

The string is not a keymap. It is the smallest representation that lets a stored preference and a
key press be compared without either side inventing its own vocabulary.

## Who owns the change, and who reads it

The **settings page owns the change**: it is the only surface that rebinds an action, and it hands
the result to the settings owner, which persists it under `webui-shortcuts.v1` — the same habit as
`webui-theme`, `mavis-locale` and `file_line_wrap`, and a key of its own so it cannot collide with
the language preference.

The **composer reads it when a key is pressed**, not when it renders. The settings page and the
composer are open at the same time, and a binding changed in the page has to be the binding the
next key press obeys; a value read at press time is that, and a value held in component state would
be whatever it was when the composer last rendered.

The composer therefore keeps no shortcut state of its own. What it owns is the rule: a press that
names the send binding submits, a press that names the newline binding breaks the line, and a press
that names neither is left to the browser — which is why a key the user unbound stops doing the
composer's business and starts doing the textarea's.

## What the page refuses

- **A modifier on its own.** A binding is a combination; capturing `Shift` alone would leave a
shortcut that fires whenever the user holds a key down.
- **A combination another action already holds.** The page says which action holds it and keeps
both bindings as they were.

## What this is not

- **Not a keybinding framework.** Two actions, one notation, one page. The voice, connection and
personalization tabs stay disabled, and nothing here changes the server or the runtime.
- **Not a new binding for stopping a generation.** 停止生成 is a button and nothing else, and the
page says so. `Escape` is already spoken for by the menus it closes — the mention menu, the slash
menu, the permission menu, goal mode and plan mode all claim it — so a shortcut for it would
fight them rather than serve the user.
- **Not the language pack.** This page's strings are hard-coded Chinese, like the rest of the
settings page on this baseline. Where the settings page's language pack lands, these strings move
with the others; the two changes touch the same tab table and nothing else.

## Consequences

- The composer no longer hard-codes what Enter means. `resolveWebuiComposerEnterAction` is retired:
the rule it encoded is the default configuration, expressed as the same values the page shows.
- The built-in behaviour is unchanged. With nothing stored, `Enter` sends and `Shift+Enter` breaks
the line, and a press that names neither binding is left to the browser — which is what the
retired rule did with every modified Enter.
- A newline bound to a key the browser does not break the line on (`Control+J`, say) is broken by
the composer itself, because a binding the user can set is a binding the user can rely on.
8 changes: 8 additions & 0 deletions packages/webui/docs/client/composer.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ needs its own behavioural evidence — browser or assembled-application, per
| Queue mirror | One selected destination with a generation, not a per-session mirror map; home display gates preserved | Refresh view interest without recreating the owner | Enqueue / delete / read commit only to an eligible destination generation; settlement obligations survive a selection change |
| Retry | Last `{session, message}` retained, shown only for the matching session and refusal, never retargeted by adoption | Retained | Retry enters the ordinary send/enqueue workflow; goals and commands create no turn-retry record |
| Mention and slash | Mention retained until an explicit command; slash derived from draft and caret | Recreate caret, index and listeners | Selection/dismiss controls the range; no universal switch or submit reset |
| Send and newline keys | The bindable keys are the stored bindings, not a rule in the component; the settings page owns the change | The stored binding is read when a key is pressed, so a rebinding takes effect without a remount | 发送消息 submits and 换行 breaks the line; a press that names neither binding is left to the browser; an open IME composition belongs to the composition |

## Where the pieces live

Expand All @@ -29,3 +30,10 @@ needs its own behavioural evidence — browser or assembled-application, per
React-side store binding.
- The submission path itself (creation, admission, send-or-queue) is
[`submission.md`](submission.md).
- The keyboard bindings live in the settings business: the page is
`client/features/settings/ui/ShortcutsSettings.tsx` with its own model in
`client/features/settings/projection/settings-shortcuts.ts`, and the notation both the page and the
composer read is `client/projection/common/shortcuts.ts`. The composer keeps no binding state —
it reads the stored binding when a key is pressed. ADR
[`../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md`](../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md)
records why.
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type KeyboardEvent, type RefObject } from "react";
import { findWebuiMentionRange, removeWebuiSlashToken, type WebuiMentionRange, type WebuiSlashRange } from "../projection/composer-interactions.js";
import { resolveWebuiComposerEnterAction } from "../projection/composer-state.js";
import { resolveWebuiComposerShortcutAction } from "../projection/composer-state.js";
import { parseShortcutBindings, shortcutBaseKey, SHORTCUT_STORAGE_KEY } from "../../../projection/common/shortcuts.js";
import { useWebuiBrowserCapabilities } from "../../../bindings/browser-capabilities.js";
import { shouldRecallWebuiHistory, startWebuiHistoryBrowse, stepWebuiHistoryBrowse, type WebuiHistoryBrowse } from "../application/composer-history.js";

export interface ComposerInputKeyOptions {
Expand Down Expand Up @@ -59,6 +61,10 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
const [mentionIndex, setMentionIndex] = useState(0);
const [commandIndex, setCommandIndex] = useState(0);
const [historyBrowse, setHistoryBrowse] = useState<WebuiHistoryBrowse>();
// The bindings are read when a key is pressed rather than held in state: the
// settings page can change them while the composer is open, and the next
// press must follow the binding the user just set.
const { storage } = useWebuiBrowserCapabilities();

useEffect(() => setHistoryBrowse(undefined), [sessionId]);

Expand All @@ -85,6 +91,27 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
input.setMentionRange(nextRange);
}, []);

/**
* Breaks the line at the caret.
*
* `Enter` breaks the line by itself, so this is only reached for a newline
* bound to a key the browser does not break the line on.
*/
const insertNewline = useCallback((event: KeyboardEvent<HTMLTextAreaElement>, options: ComposerInputKeyOptions) => {
const caret = event.currentTarget.selectionStart ?? options.draft.length;
const value = `${options.draft.slice(0, caret)}\n${options.draft.slice(caret)}`;
pendingCaret.current = caret + 1;
edit({
value,
caret: caret + 1,
changeDraft: options.onHistoryDraftChange ?? options.onDraftChange,
mentionRange: options.mentionRange,
setMentionRange: options.setMentionRange,
setComposerMenu: options.setComposerMenu,
setPermissionMenuOpen: options.setPermissionMenuOpen,
});
}, [edit]);

const updateCaret: ReturnType<typeof useComposerInput>["updateCaret"] = useCallback((value, nextCaret, _mentionRange, setMentionRange) => {
setCaret(nextCaret);
setMentionRange(findWebuiMentionRange(value, nextCaret));
Expand Down Expand Up @@ -145,20 +172,31 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
return;
}
}
if (event.key === "Enter" && options.commandCount === 0) {
const action = resolveWebuiComposerEnterAction({
shiftKey: event.shiftKey,
altKey: event.altKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
if (options.commandCount === 0) {
const bindings = parseShortcutBindings(storage.getItem(SHORTCUT_STORAGE_KEY));
const action = resolveWebuiComposerShortcutAction({
event: {
key: event.key,
code: event.code,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
altKey: event.altKey,
shiftKey: event.shiftKey,
},
isComposing: event.nativeEvent.isComposing,
submitBlocked: options.submitBlocked,
bindings,
});
if (action === "submit") {
event.preventDefault();
event.currentTarget.form?.requestSubmit();
return;
}
if (action === "newline" && shortcutBaseKey(bindings["composer.newline"]) !== "Enter") {
event.preventDefault();
insertNewline(event, options);
return;
}
}
if (options.commandCount === 0) return;
if (event.key === "ArrowDown") {
Expand All @@ -171,7 +209,7 @@ export function useComposerInput(draft: string, sessionId?: string): ComposerInp
event.preventDefault();
options.chooseCommand(commandIndex);
}
}, [applyHistoryDraft, commandIndex, historyBrowse, mentionIndex]);
}, [applyHistoryDraft, commandIndex, historyBrowse, insertNewline, mentionIndex, storage]);

return {
textareaRef, caret, setCaret, mentionIndex, setMentionIndex, commandIndex, setCommandIndex,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
WebuiSlashCommandFields,
} from "../contracts/slash-command.js";
import { classifyWebuiSlashCommand } from "../contracts/slash-command.js";
import { shortcutMatches, type ShortcutBindings, type ShortcutKeyEvent } from "../../../projection/common/shortcuts.js";
export type WebuiSubmissionIntent =
| { readonly kind: "activate-goal-mode" }
| { readonly kind: "activate-plan-mode" }
Expand Down Expand Up @@ -55,19 +56,33 @@ export function resolveWebuiSubmissionIntent(args: {
return { kind: "submit-turn" };
}

export type WebuiComposerEnterAction = "submit" | "newline";
export type WebuiComposerShortcutAction = "submit" | "newline" | "ignore";

export function resolveWebuiComposerEnterAction(args: {
readonly shiftKey: boolean;
readonly altKey: boolean;
readonly ctrlKey: boolean;
readonly metaKey: boolean;
/**
* What a key press in the composer does.
*
* The rule is the configured binding, not a hard-coded one: the settings page
* can bind "send the message" to any combination, and the composer follows it.
* `ignore` is the answer for a press that names no binding at all — the
* browser's own behaviour then stands, which is how a key that is not bound
* keeps doing what a key in a textarea does.
*
* The default bindings (`Enter` sends, `Shift+Enter` breaks the line) reproduce
* the rule this composer had before the bindings were rebindable.
*/
export function resolveWebuiComposerShortcutAction(args: {
readonly event: ShortcutKeyEvent;
readonly isComposing: boolean;
readonly submitBlocked: boolean;
}): WebuiComposerEnterAction {
if (args.shiftKey || args.altKey || args.ctrlKey || args.metaKey) return "newline";
if (args.isComposing || args.submitBlocked) return "newline";
return "submit";
readonly bindings: ShortcutBindings;
}): WebuiComposerShortcutAction {
// While an input method is composing, Enter belongs to the composition.
if (args.isComposing) return "ignore";
if (shortcutMatches(args.bindings["composer.submit"], args.event)) {
return args.submitBlocked ? "ignore" : "submit";
}
if (shortcutMatches(args.bindings["composer.newline"], args.event)) return "newline";
return "ignore";
}

export function deriveRecentWorkspaceDirs(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// The shortcuts page's own model: which actions it lists, what each one is
// called and which of them can be bound at all.
//
// The page renders this and the composer reads the bindings themselves, so the
// names live here rather than in the component that draws the rows.

import {
shortcutDisplayLabel,
type ShortcutActionId,
type ShortcutBindings,
} from "../../../projection/common/shortcuts.js";

export interface ShortcutSettingsAction {
readonly actionId: ShortcutActionId;
/** The control's test id. It is a page detail, not a domain name. */
readonly testId: string;
readonly title: string;
readonly description: string;
/** Whether the row accepts a new binding. */
readonly editable: boolean;
/** What the row says for an action that has no key binding at all. */
readonly note?: string;
}

/**
* The actions the page lists.
*
* Only the two composer actions that already have keyboard meaning are
* bindable. Stopping a generation is a button and nothing else: `Escape` is
* already spoken for by the menus it closes, so the row says so rather than
* offering a binding that would fight them.
*/
export const SHORTCUT_SETTINGS_ACTIONS: readonly ShortcutSettingsAction[] = [
{
actionId: "composer.submit",
testId: "mavis-settings-shortcut-submit",
title: "发送消息",
description: "在输入框中发送当前消息",
editable: true,
},
{
actionId: "composer.newline",
testId: "mavis-settings-shortcut-newline",
title: "换行",
description: "在输入框中换行,不发送消息",
editable: true,
},
{
actionId: "composer.stop",
testId: "mavis-settings-shortcut-stop",
title: "停止生成",
description: "仅按钮,无快捷键",
editable: false,
note: "仅按钮,无快捷键",
},
];

export interface ShortcutSettingsRow extends ShortcutSettingsAction {
/** The action's current binding, in its canonical form. */
readonly shortcut: string;
/** How the current binding is shown. */
readonly label: string;
}

/** The page's rows, for a set of bindings. */
export function shortcutSettingsRows(bindings: ShortcutBindings): readonly ShortcutSettingsRow[] {
return SHORTCUT_SETTINGS_ACTIONS.map((action) => {
const shortcut = bindings[action.actionId] ?? "";
return {
...action,
shortcut,
label: shortcut ? shortcutDisplayLabel(shortcut) : "未设置",
};
});
}

/** The name of an action, for a message that names the action it collided with. */
export function shortcutActionTitle(actionId: ShortcutActionId): string {
return SHORTCUT_SETTINGS_ACTIONS.find((action) => action.actionId === actionId)?.title ?? actionId;
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export type SettingsTabKey = "desktop" | "shortcuts" | "voice" | "custom-instruc
export interface SettingsTabDefinition { readonly key: SettingsTabKey; readonly group: "preferences" | "management" | "coding" | "archived"; readonly label: string; readonly icon: string; readonly disabled?: boolean; }

export const DESKTOP_SETTINGS_TABS: readonly SettingsTabDefinition[] = [
{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts", disabled: true }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" },
{ key: "desktop", group: "preferences", label: "通用", icon: "desktop" }, { key: "voice", group: "preferences", label: "语音", icon: "voice", disabled: true }, { key: "shortcuts", group: "preferences", label: "快捷键", icon: "shortcuts" }, { key: "custom-instructions", group: "preferences", label: "个性化", icon: "custom-instructions" },
// The browser switches were under the browser panel, in the tool area. A
// setting that lives in a panel is a setting nobody finds without opening
// that panel first, so they are a page of their own — under 偏好, next to
Expand Down
Loading
Loading