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
31 changes: 31 additions & 0 deletions packages/webui/src/client/components/SessionComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import {
WebuiIconSkillGeneric,
type WebuiIconProps,
} from "../icons.js";
import { useWebuiVoice } from "../use-webui-voice.js";
import { OutputError } from "./OutputError.js";
import {
createWebuiWatchEventCallback,
Expand Down Expand Up @@ -1675,6 +1676,19 @@ export function WebuiComposer({
}
onDraftChange(next);
};

/* Roadmap P 区「语音输入」.
*
* The button only exists when the feature is switched on AND this browser
* can actually listen. Firefox has never shipped `SpeechRecognition`, and a
* control that appears and then never reacts costs the user a click and
* explains nothing — so the unsupported case renders no button at all, and
* the settings page says why.
*
* Recognised text goes through `handleDraftChange` rather than straight to
* the setter so it lands in the draft the same way typing does, including
* the `/goal` command interception. */
const voice = useWebuiVoice({ draft, setDraft: handleDraftChange });
const chooseCommand = (command: string) => {
if (command === "goal") {
activateGoalMode();
Expand Down Expand Up @@ -2434,6 +2448,23 @@ export function WebuiComposer({
</div>
) : null}
</div>
{voice.available ? (
<button
type="button"
className={`webui-composer-voice${voice.state.phase === "listening" ? " webui-composer-voice--listening" : ""}`}
aria-label={voice.state.phase === "listening" ? "停止语音输入" : "开始语音输入"}
aria-pressed={voice.state.phase === "listening"}
title={voice.state.error ?? undefined}
data-testid="composer-voice"
onClick={voice.toggle}
>
<svg aria-hidden="true" className="webui-composer-voice-icon" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round">
<rect x="6.6" y="2.2" width="4.8" height="8.4" rx="2.4" />
<path d="M3.9 8.6a5.1 5.1 0 0 0 10.2 0" />
<path d="M9 13.7v2.1" />
</svg>
</button>
) : null}
<div className="webui-composer-permission-wrap" ref={permissionWrapRef}>
<button
type="button"
Expand Down
212 changes: 210 additions & 2 deletions packages/webui/src/client/components/SettingsModal.tsx

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions packages/webui/src/client/components/UserMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ type WebuiUserMenuCapabilities = WebuiSettingsModalCapabilities;

interface UserMenuProps {
readonly collapsed: boolean;
/* Bumped by the shell's global shortcut handler. The settings dialog is
* mounted from here, so a `Ctrl+,` press has to reach this component
* somehow; a counter survives the component not re-rendering and is cheaper
* to wire than a new callback. */
readonly openSettingsSignal?: number;
readonly hostLabel?: string;
readonly dataDir?: string;
readonly version?: WebuiVersionInfo;
Expand Down Expand Up @@ -728,6 +733,7 @@ export function UserMenu({
getSigninPanel,
claimSignin,
onCreateMemorySession,
openSettingsSignal,
}: UserMenuProps): ReactElement {
// Bound once per transport, not per render: the login dialog's effects
// key on these callbacks, and a fresh binding every render would restart
Expand All @@ -751,6 +757,17 @@ export function UserMenu({
}, [onCreateMemorySession]);
const [open, setOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);

/* The global `openSettings` shortcut arrives as a counter rather than a
* callback so that pressing the combination twice while the dialog is
* already open re-focuses it instead of being swallowed as a duplicate. */
const lastSettingsSignal = useRef(0);
useEffect(() => {
if (openSettingsSignal === undefined || openSettingsSignal === lastSettingsSignal.current) return;
lastSettingsSignal.current = openSettingsSignal;
setOpen(false);
setSettingsOpen(true);
}, [openSettingsSignal]);
const [loginOpen, setLoginOpen] = useState(false);
const [usageOpen, setUsageOpen] = useState(false);
const [usage, setUsage] = useState<UsageState>({ status: "idle" });
Expand Down
74 changes: 74 additions & 0 deletions packages/webui/src/client/components/WebuiClientFoundationApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ import { createSessionStreamRetry } from "../session-stream-retry.js";
import { ConnectionStatus } from "../ConnectionStatus.js";
import { deriveConversationUsageNotice } from "../projection/message-projection.js";
import { deriveRecentWorkspaceDirs } from "../projection/composer-state.js";
import {
WEBUI_SHORTCUT_OVERRIDES_KEY,
matchesWebuiShortcut,
parseWebuiShortcutOverrides,
resolveWebuiShortcutBindings,
} from "../projection/shortcut-state.js";

/**
* Hash helpers used by the shell. `main.tsx` also calls
Expand Down Expand Up @@ -867,6 +873,73 @@ export function WebuiClientFoundationApp(
? teamModeChoices[selectedSessionId] ?? teamModeOff
: teamModeOff;
const [railCollapsed, setRailCollapsed] = useState(false);

/* Roadmap P 区「快捷键管理」.
*
* The user menu has always labelled its settings row `Ctrl+,` and nothing
* ever handled that combination — the label was decoration. This is the one
* place that now makes it true.
*
* Every branch here corresponds to a row in `WEBUI_SHORTCUT_COMMANDS`. A
* command with no branch behind it would render in the settings page and do
* nothing, which is the defect this whole change removes, so the registry's
* tests assert both directions. */
const [settingsSignal, setSettingsSignal] = useState(0);
const [pendingSettingsSignal, setPendingSettingsSignal] = useState(false);
useEffect(() => {
const runShortcut = (commandId: string): void => {
if (commandId === "toggleSidebar") {
setRailCollapsed((collapsed) => !collapsed);
return;
}
if (commandId === "openSettings") {
// The settings dialog is mounted by UserMenu, which only exists while
// the rail is expanded. Firing into nothing is the one outcome worse
// than a shortcut that does not exist, so the rail opens first and the
// signal lands once the menu has mounted.
if (railCollapsed) {
setRailCollapsed(false);
setPendingSettingsSignal(true);
return;
}
setSettingsSignal((signal) => signal + 1);
return;
}
if (commandId === "focusComposer") {
// Queried rather than passed down: the composer owns its own ref, and
// threading one through FoundationApp for a focus call would be a
// wider change than the shortcut earns. `name="content"` is the
// textarea's existing identity and is unique in the shell.
document.querySelector<HTMLTextAreaElement>('textarea[name="content"]')?.focus();
}
};

const onKeyDown = (event: KeyboardEvent): void => {
const platform = typeof navigator === "undefined" ? undefined : navigator.platform || undefined;
for (const [commandId, binding] of resolveWebuiShortcutBindings(
parseWebuiShortcutOverrides(localStorage.getItem(WEBUI_SHORTCUT_OVERRIDES_KEY)),
)) {
if (!matchesWebuiShortcut(event, binding, platform)) continue;
// A binding the user set while typing must not eat the keystroke on
// its way to the textarea, or the character never lands.
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
if (!event.ctrlKey && !event.metaKey && !event.altKey) return;
}
event.preventDefault();
runShortcut(commandId);
return;
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [railCollapsed]);

useEffect(() => {
if (!pendingSettingsSignal || railCollapsed) return;
setPendingSettingsSignal(false);
setSettingsSignal((signal) => signal + 1);
}, [pendingSettingsSignal, railCollapsed]);
// Rail session search. `railSearchOpen` mirrors whether the input is
// showing; `railSearchQuery` is the live filter. The query filters only the
// rail's rendered list — lookups such as the selected-session resolution and
Expand Down Expand Up @@ -1269,6 +1342,7 @@ export function WebuiClientFoundationApp(
version={runtimeVersion}
sessionId={selectedSessionId}
workspaceDir={selectedSession?.workspaceDir}
openSettingsSignal={settingsSignal}
onOpenFileLine={(path, line) => {
// The open-file command needs a concrete session and
// workspace; the review page is only reachable from a
Expand Down
Loading