Skip to content
25 changes: 17 additions & 8 deletions apps/desktop/src/features/control-panel/ControlPanelApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ import {
type SecurityBudgetDisplaySettings,
} from "@/services/securityBudgetDisplayService";
import { loadDesktopRuntimeDefaultsSnapshot, loadHydratedSettings } from "@/services/settingsService";
import {
resolveVoiceNotificationVoicePreset,
VOICE_NOTIFICATION_VOICE_PRESET_OPTIONS,
type VoiceNotificationVoicePreset,
} from "@/services/voiceNotificationConfig";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { buildDesktopOnboardingPresentation } from "@/features/onboarding/onboardingGeometry";
import {
Expand Down Expand Up @@ -510,19 +515,21 @@ function ToggleLine({ checked, description, disabled = false, label, onCheckedCh

function ChoiceGroup<T extends string>({
className,
disabled = false,
options,
value,
onValueChange,
}: {
className?: string;
disabled?: boolean;
options: readonly ChoiceOption<T>[];
value: T;
onValueChange: (value: T) => void;
}) {
const classes = ["control-panel-shell__choice-group", className].filter(Boolean).join(" ");

return (
<div className={classes} role="radiogroup">
<div className={classes} role="radiogroup" aria-disabled={disabled}>
{options.map((option) => {
const checked = option.value === value;

Expand All @@ -532,6 +539,7 @@ function ChoiceGroup<T extends string>({
type="button"
role="radio"
aria-checked={checked}
disabled={disabled}
className="control-panel-shell__choice-option"
data-state={checked ? "checked" : "unchecked"}
onClick={() => onValueChange(option.value)}
Expand Down Expand Up @@ -1281,7 +1289,7 @@ export function ControlPanelApp() {

<ToggleLine
label="语音通知"
description="控制应用内语音提示和音效反馈。"
description="控制悬浮球启动、选中文本检测、剪贴板检测、选中确认和正式任务通知的本地语音提示;保存后同步到其他桌面窗口。"
checked={draft.settings.general.voice_notification_enabled}
onCheckedChange={(checked) =>
updateSettings((current) => ({
Expand All @@ -1296,19 +1304,20 @@ export function ControlPanelApp() {

<ControlLine
label="提示声线"
hint="控制正式 `general.voice_type`,保存后重新打开控制面板会回显当前值。"
hint="控制桌面端本地语音播报使用的 `general.voice_type`;当前提供更明确的预设声线按钮,默认优先选择更接近小女孩的中文系统声线。"
disabled={!draft.settings.general.voice_notification_enabled}
>
<TextField.Root
className="control-panel-shell__input"
<ChoiceGroup
className="control-panel-shell__choice-group--wide"
disabled={!draft.settings.general.voice_notification_enabled}
value={draft.settings.general.voice_type}
onChange={(event) =>
options={VOICE_NOTIFICATION_VOICE_PRESET_OPTIONS}
value={resolveVoiceNotificationVoicePreset(draft.settings.general.voice_type)}
onValueChange={(value: VoiceNotificationVoicePreset) =>
updateSettings((current) => ({
...current,
settings: {
...current.settings,
general: { ...current.settings.general, voice_type: event.target.value },
general: { ...current.settings.general, voice_type: value },
},
}))
}
Expand Down
85 changes: 67 additions & 18 deletions apps/desktop/src/features/shell-ball/ShellBallApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import { ShellBallBubbleZone } from "./components/ShellBallBubbleZone";
import { ShellBallInputBar } from "./components/ShellBallInputBar";
import { ShellBallVoiceHints } from "./components/ShellBallVoiceHints";
import { areShellBallSelectionSnapshotsEqual } from "./selection/selection.provider";
import type { ShellBallSelectionSnapshot } from "./selection/selection.types";
import { useShellBallInteraction } from "./useShellBallInteraction";
import { getShellBallMotionConfig } from "./shellBall.motion";
Expand All @@ -39,6 +40,12 @@
setShellBallPressLock,
} from "../../platform/shellBallWindow";
import { loadSettings } from "../../services/settingsService";
import {
speakShellBallClipboardDetectedNotification,
speakShellBallSelectionDetectedNotification,
speakShellBallSelectionClickGreeting,
speakShellBallStartupGreeting,
} from "../../services/voiceNotificationService";
import { openOrFocusDesktopWindow } from "../../platform/windowController";
import { buildDesktopOnboardingPresentation } from "@/features/onboarding/onboardingGeometry";
import {
Expand Down Expand Up @@ -74,7 +81,7 @@
const SHELL_BALL_EDGE_DOCK_REVEAL_GUARD_PX = 16;
const SHELL_BALL_EDGE_DOCK_REVEAL_HIDE_DELAY_MS = 90;

export function normalizeShellBallFloatingSize(size: string | null | undefined): ShellBallFloatingSize {

Check warning on line 84 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 84 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
if (size === "small" || size === "medium" || size === "large") {
return size;
}
Expand All @@ -82,7 +89,7 @@
return "medium";
}

export function shouldRetainShellBallEdgeDockReveal(input: {

Check warning on line 92 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 92 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
bounds: ShellBallEdgeDockRevealBounds;
edgeDockSide: ShellBallEdgeDockSide | null;
guardPx?: number;
Expand Down Expand Up @@ -138,7 +145,7 @@
* @param input File-drop visibility inputs from the ball window.
* @returns Whether the overlay should be rendered.
*/
export function shouldShowShellBallFileDropOverlay(input: {

Check warning on line 148 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 148 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
fileDropActive: boolean;
}) {
return input.fileDropActive;
Expand All @@ -151,7 +158,7 @@
* @param input Drag and state metadata from the shell-ball window.
* @returns Whether text drag affordances should be active.
*/
export function shouldArmShellBallTextDropTarget(input: {

Check warning on line 161 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 161 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
fileDropActive: boolean;
textDragActive: boolean;
visualState: ShellBallVisualState;
Expand Down Expand Up @@ -179,7 +186,7 @@
* @param input Selection availability plus the current shell-ball state.
* @returns Whether the marker should be shown.
*/
export function shouldShowShellBallSelectionIndicator(input: {

Check warning on line 189 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 189 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
selection: ShellBallSelectionSnapshot | null;
visualState: ShellBallVisualState;
}) {
Expand Down Expand Up @@ -220,14 +227,14 @@
* @param now Current timestamp in milliseconds.
* @returns Whether the clipboard prompt should trigger a backend submit.
*/
export function isShellBallClipboardPromptActive(

Check warning on line 230 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 230 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
prompt: ShellBallClipboardPrompt | null,
now = Date.now(),
) {
return prompt !== null && prompt.expiresAt > now;
}

export function resolveShellBallInlineInputMode(input: {

Check warning on line 237 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components

Check warning on line 237 in apps/desktop/src/features/shell-ball/ShellBallApp.tsx

View workflow job for this annotation

GitHub Actions / desktop-windows-build

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components
shouldRenderInlineInput: boolean;
snapshotInputBarMode: ShellBallInputBarMode;
forceInteractive?: boolean;
Expand Down Expand Up @@ -391,9 +398,12 @@
const clipboardPromptClearTimeoutRef = useRef<number | null>(null);
const selectionPromptClearTimeoutRef = useRef<number | null>(null);
const selectionPromptExpiryTimeoutRef = useRef<number | null>(null);
const lastSpokenSelectionPromptRef = useRef<ShellBallSelectionSnapshot | null>(null);
const lastSpokenClipboardPromptTextRef = useRef<string | null>(null);
const previousVisualStateRef = useRef<ShellBallVisualState>(visualState);
const transitionQueueRef = useRef(Promise.resolve());
const edgeDockRevealHideTimeoutRef = useRef<number | null>(null);
const startupGreetingPlayedRef = useRef(false);
const dragDropHandlersRef = useRef<{
handleDroppedFiles: (paths: string[]) => Promise<void> | void;
}>({
Expand Down Expand Up @@ -482,6 +492,16 @@
window.removeEventListener("storage", syncFloatingBallSizeFromStorage);
};
}, []);

useEffect(() => {
if (startupGreetingPlayedRef.current) {
return;
}

startupGreetingPlayedRef.current = true;
void speakShellBallStartupGreeting();
}, []);

const {
ballDockSettling,
ballDragActive,
Expand Down Expand Up @@ -903,6 +923,16 @@
setTextDragActive(false);
}, []);

const clearSelectionPrompt = useCallback(() => {
lastSpokenSelectionPromptRef.current = null;
setSelectionPrompt(null);
}, []);

const clearClipboardPrompt = useCallback(() => {
lastSpokenClipboardPromptTextRef.current = null;
setClipboardPrompt(null);
}, []);

const handleWindowTextDrag = useCallback((event: DragEvent) => {
if (!shouldAcceptShellBallTextDrop(event.dataTransfer)) {
clearTextDragState();
Expand Down Expand Up @@ -952,9 +982,9 @@
window.clearTimeout(selectionPromptClearTimeoutRef.current);
selectionPromptClearTimeoutRef.current = null;
}
setSelectionPrompt(null);
clearSelectionPrompt();
}
}, [visualState]);
}, [clearSelectionPrompt, visualState]);

useEffect(() => {
if (selectionPrompt === null) {
Expand All @@ -967,19 +997,19 @@

const updatedAtMs = resolveShellBallSelectionUpdatedAtMs(selectionPrompt.updated_at);
if (updatedAtMs === null) {
setSelectionPrompt(null);
clearSelectionPrompt();
return;
}

const remainingMs = updatedAtMs + SHELL_BALL_SELECTION_PROMPT_WINDOW_MS - Date.now();
if (remainingMs <= 0) {
setSelectionPrompt(null);
clearSelectionPrompt();
return;
}

selectionPromptExpiryTimeoutRef.current = window.setTimeout(() => {
selectionPromptExpiryTimeoutRef.current = null;
setSelectionPrompt(null);
clearSelectionPrompt();
}, remainingMs);

return () => {
Expand All @@ -988,7 +1018,7 @@
selectionPromptExpiryTimeoutRef.current = null;
}
};
}, [selectionPrompt]);
}, [clearSelectionPrompt, selectionPrompt]);

useEffect(() => {
if (clipboardPrompt === null) {
Expand All @@ -1001,13 +1031,13 @@

const remainingMs = clipboardPrompt.expiresAt - Date.now();
if (remainingMs <= 0) {
setClipboardPrompt(null);
clearClipboardPrompt();
return;
}

clipboardPromptClearTimeoutRef.current = window.setTimeout(() => {
clipboardPromptClearTimeoutRef.current = null;
setClipboardPrompt(null);
clearClipboardPrompt();
}, remainingMs);

return () => {
Expand All @@ -1016,7 +1046,7 @@
clipboardPromptClearTimeoutRef.current = null;
}
};
}, [clipboardPrompt]);
}, [clearClipboardPrompt, clipboardPrompt]);

useEffect(() => {
const currentWindow = getCurrentWindow();
Expand All @@ -1036,6 +1066,11 @@
selectionPromptClearTimeoutRef.current = null;
}

if (!areShellBallSelectionSnapshotsEqual(lastSpokenSelectionPromptRef.current, payload.snapshot)) {
lastSpokenSelectionPromptRef.current = payload.snapshot;
void speakShellBallSelectionDetectedNotification();
}

setSelectionPrompt(payload.snapshot);
return;
}
Expand All @@ -1049,7 +1084,7 @@
// a short debounce before retiring the alert opportunity.
selectionPromptClearTimeoutRef.current = window.setTimeout(() => {
selectionPromptClearTimeoutRef.current = null;
setSelectionPrompt(null);
clearSelectionPrompt();
}, SHELL_BALL_SELECTION_PROMPT_CLEAR_DELAY_MS);
})
.then((unlisten) => {
Expand All @@ -1069,7 +1104,7 @@
}
cleanup?.();
};
}, []);
}, [clearSelectionPrompt]);

useEffect(() => {
const currentWindow = getCurrentWindow();
Expand All @@ -1084,10 +1119,15 @@
void currentWindow
.listen<ShellBallClipboardSnapshotPayload>(shellBallWindowSyncEvents.clipboardSnapshot, ({ payload }) => {
if (payload.text.trim() === "") {
setClipboardPrompt(null);
clearClipboardPrompt();
return;
}

if (lastSpokenClipboardPromptTextRef.current !== payload.text) {
lastSpokenClipboardPromptTextRef.current = payload.text;
void speakShellBallClipboardDetectedNotification();
}

setClipboardPrompt({
text: payload.text,
expiresAt: Date.now() + SHELL_BALL_CLIPBOARD_PROMPT_WINDOW_MS,
Expand All @@ -1106,7 +1146,7 @@
disposed = true;
cleanup?.();
};
}, []);
}, [clearClipboardPrompt]);

const handleMascotPrimaryAction = useCallback(() => {
if (isShellBallSelectionPromptActive(selectionPrompt)) {
Expand All @@ -1120,29 +1160,38 @@
return;
}

setSelectionPrompt(null);
void speakShellBallSelectionClickGreeting();
clearSelectionPrompt();
void handleCoordinatorSelectedTextPrompt(activeSelectionPrompt);
return;
}

if (selectionPrompt !== null) {
setSelectionPrompt(null);
clearSelectionPrompt();
return;
}

if (clipboardPrompt !== null) {
if (!isShellBallClipboardPromptActive(clipboardPrompt)) {
setClipboardPrompt(null);
clearClipboardPrompt();
return;
}

setClipboardPrompt(null);
clearClipboardPrompt();
void handleCoordinatorClipboardPrompt(clipboardPrompt.text);
return;
}

handlePrimaryClick();
}, [clipboardPrompt, handleCoordinatorClipboardPrompt, handleCoordinatorSelectedTextPrompt, handlePrimaryClick, selectionPrompt]);
}, [
clearClipboardPrompt,
clearSelectionPrompt,
clipboardPrompt,
handleCoordinatorClipboardPrompt,
handleCoordinatorSelectedTextPrompt,
handlePrimaryClick,
selectionPrompt,
]);

const handleDockAwareRegionEnter = useCallback((event: ReactPointerEvent<HTMLButtonElement>) => {
void event;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ export function getShellBallMascotHotspotGestureAction(input: {
}

if (input.gesture === "single_click") {
return input.selectionIndicatorVisible || input.alertOpportunityAvailable ? "primary_click" : "noop";
return input.selectionIndicatorVisible || input.alertOpportunityAvailable || input.visualState === "idle"
? "primary_click"
: "noop";
}

if (canTriggerShellBallMascotSecondaryGestures(input.visualState)) {
Expand Down
Loading
Loading