diff --git a/.github/workflows/build-macos-arm64-dmg.yml b/.github/workflows/build-macos-arm64-dmg.yml index 19479538..a96c1da8 100644 --- a/.github/workflows/build-macos-arm64-dmg.yml +++ b/.github/workflows/build-macos-arm64-dmg.yml @@ -32,7 +32,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: "20" + node-version: "22" - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/oc-review.yml b/.github/workflows/oc-review.yml index 65e49094..655b6665 100644 --- a/.github/workflows/oc-review.yml +++ b/.github/workflows/oc-review.yml @@ -18,7 +18,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release-desktop-smoke.yml b/.github/workflows/release-desktop-smoke.yml index 7686eb7c..e81407d8 100644 --- a/.github/workflows/release-desktop-smoke.yml +++ b/.github/workflows/release-desktop-smoke.yml @@ -65,7 +65,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -206,7 +206,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 68390b3f..30148426 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -90,7 +90,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' registry-url: 'https://registry.npmjs.org' - name: Install dependencies @@ -141,7 +141,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -288,7 +288,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile @@ -365,7 +365,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Download per-arch latest-mac.yml uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 diff --git a/.github/workflows/vscode-extension.yml b/.github/workflows/vscode-extension.yml index 566549c1..ef9ac3a2 100644 --- a/.github/workflows/vscode-extension.yml +++ b/.github/workflows/vscode-extension.yml @@ -25,7 +25,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: - node-version: '20' + node-version: '22' - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.gitignore b/.gitignore index 305aec4c..8a344a6b 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ data/ workspaces/ *.pid .worktrees/ +test-results/ diff --git a/bun.lock b/bun.lock index 37335213..1e4d7467 100644 --- a/bun.lock +++ b/bun.lock @@ -283,6 +283,7 @@ "openai": "^4.79.0", "qrcode-terminal": "^0.12.0", "reflect-metadata": "^0.2.2", + "sherpa-onnx-node": "1.12.28", "simple-git": "^3.28.0", "web-push": "^3.6.7", "ws": "^8.18.3", @@ -2980,6 +2981,20 @@ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "sherpa-onnx-darwin-arm64": ["sherpa-onnx-darwin-arm64@1.13.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9x86Cbf+BDFONdtCPM3cnjvtAW0ER8tMaHK5pVfz+SHPt8GeuwRXaiR/BzcByFBUyxCgmceO09/WMZOCi44P/g=="], + + "sherpa-onnx-darwin-x64": ["sherpa-onnx-darwin-x64@1.13.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-TVQ35g7JIpDPB1lUDdcog+JtI0cI45ZzOnvHXm0DtWs/dgxnJXtWMY3uLRtBbLnysV9j5ljffwZ1IX9VDHsCzQ=="], + + "sherpa-onnx-linux-arm64": ["sherpa-onnx-linux-arm64@1.13.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-uDtZkkoP6QQ/3DHOscCpEZ2WpaiHUQsDpbyYaHURrJ7DbsjqGnS6G8l+R589Ro5Bf282QElzBy3okwxXbt3Kxw=="], + + "sherpa-onnx-linux-x64": ["sherpa-onnx-linux-x64@1.13.3", "", { "os": "linux", "cpu": "x64" }, "sha512-OFVK0GYwKwKNsjxbPmfcLQm/dfA0IwAoiIQJ96s+eFYcDqhlapcY06ocdb7SNluGBcM7xgU5jEW2QXBkMIOEvQ=="], + + "sherpa-onnx-node": ["sherpa-onnx-node@1.12.28", "", { "optionalDependencies": { "sherpa-onnx-darwin-arm64": "^1.12.28", "sherpa-onnx-darwin-x64": "^1.12.28", "sherpa-onnx-linux-arm64": "^1.12.28", "sherpa-onnx-linux-x64": "^1.12.28", "sherpa-onnx-win-ia32": "^1.12.28", "sherpa-onnx-win-x64": "^1.12.28" } }, "sha512-EHSB3EG6hKyXaTNh6GU/bwh6i3dncCH6ZCU2mScNzkxRbVStZ7QmNj0Oo4E9XrGGo9jX9pKg9MPHEPyjdK+ApA=="], + + "sherpa-onnx-win-ia32": ["sherpa-onnx-win-ia32@1.13.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-VDZh1M7Ccx/bkP3WwBCFoJzwAwq+b5nR1KRkYRz5p1w5bfhzfa3ACBGr7vpUt5AGUge4qSLe0MSKXyKtSmy1uA=="], + + "sherpa-onnx-win-x64": ["sherpa-onnx-win-x64@1.13.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ZQzcSmFvZK4jzmtWckqxocDUuEjYnBV2MHrDD21HPTeUMfGdE9yfvuSPpesIVfdzKbQzIQY42RAcfZEGWu0FbQ=="], + "shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], diff --git a/packages/mobile/android/app/src/main/AndroidManifest.xml b/packages/mobile/android/app/src/main/AndroidManifest.xml index 0229e443..9ef6ac5c 100644 --- a/packages/mobile/android/app/src/main/AndroidManifest.xml +++ b/packages/mobile/android/app/src/main/AndroidManifest.xml @@ -53,4 +53,10 @@ + + + + diff --git a/packages/mobile/ios/App/App/Info.plist b/packages/mobile/ios/App/App/Info.plist index 3ffde0d6..2d96c874 100644 --- a/packages/mobile/ios/App/App/Info.plist +++ b/packages/mobile/ios/App/App/Info.plist @@ -35,6 +35,8 @@ OpenChamber connects to OpenChamber servers on your local network. NSCameraUsageDescription OpenChamber uses the camera to scan a server's pairing QR code. + NSMicrophoneUsageDescription + OpenChamber uses the microphone for voice dictation in the chat composer. CFBundleURLTypes diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 842e8372..9159a9e0 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -44,7 +44,6 @@ import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay'; import { AboutDialog } from '@/components/ui/AboutDialog'; import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider'; import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; -import { VoiceProvider } from '@/components/voice'; import { useUIStore } from '@/stores/useUIStore'; import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore'; import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore'; @@ -928,8 +927,8 @@ function App({ apis }: AppProps) { } // Always mount the full provider tree to avoid remounts when isInitialized - // flips from false → true. FireworksProvider and VoiceProvider are lightweight - // shells; their heavy children are only activated when actually needed. + // flips from false → true. FireworksProvider is a lightweight shell; its + // heavy children are only activated when actually needed. const isBootShell = !isInitialized && !isDesktopRuntime; return ( @@ -937,7 +936,6 @@ function App({ apis }: AppProps) { -
@@ -955,7 +953,6 @@ function App({ apis }: AppProps) { )}
-
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index be6bd133..3d32a657 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Textarea } from '@/components/ui/textarea'; -import { BrowserVoiceButton } from '@/components/voice'; +import { ComposerDictation } from '@/components/dictation/ComposerDictation'; // sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; @@ -381,7 +381,7 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined = }; const MemoModelControls = React.memo(ModelControls); -const MemoBrowserVoiceButton = React.memo(BrowserVoiceButton); +const MemoComposerDictation = React.memo(ComposerDictation); const MemoMobileAgentButton = React.memo(MobileAgentButton); const MemoMobileModelButton = React.memo(MobileModelButton); const MemoStatusRow = React.memo(StatusRow); @@ -2318,6 +2318,33 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo void handleSubmitRef.current(); }, []); + // Dictation: insert the transcript inline; optionally submit immediately. + // getCurrentInputSnapshot reads textareaRef.current.value first, so setting + // it synchronously lets handleSubmit pick up the text in the same tick. + const handleDictationInsert = React.useCallback((text: string) => { + setMessage((prev) => { + const next = appendInlineText(prev, text); + const textarea = textareaRef.current; + if (textarea) { + textarea.value = next; + } + return next; + }); + setTimeout(() => { + textareaRef.current?.focus(); + }, 0); + }, []); + + const handleDictationInsertAndSend = React.useCallback((text: string) => { + const textarea = textareaRef.current; + const next = appendInlineText(textarea?.value ?? messageRef.current, text); + if (textarea) { + textarea.value = next; + } + setMessage(next); + void handleSubmitRef.current(); + }, []); + // Preset chips rendered outside this component (e.g. under the welcome // message on narrow surfaces) request a submit via the input store; consume // it here so it routes through the same command-aware submit path. @@ -4420,6 +4447,9 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo : undefined} /> )} + {/* Positioning context for the dictation overlay: covers the + text area + footer exactly, excluding MobileSessionStatusBar. */} +
@@ -4559,7 +4589,16 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo />
- + = ({ onOpenSettings, scrollTo
- + = ({ onOpenSettings, scrollTo )}
+
{/* Mobile session panel: slide-up overlay toggled by MobileSessionPanelTrigger. */} {isMobile && } diff --git a/packages/ui/src/components/dictation/ComposerDictation.tsx b/packages/ui/src/components/dictation/ComposerDictation.tsx new file mode 100644 index 00000000..7f3fd913 --- /dev/null +++ b/packages/ui/src/components/dictation/ComposerDictation.tsx @@ -0,0 +1,439 @@ +/** + * Composer dictation controls: a mic button for the composer footer plus a + * full-composer overlay while dictation is active (recording, transcribing, + * or failed). The overlay mirrors the composer's own layout — the transcript + * area uses the same paddings/typography as the textarea and the action row + * reuses the footer icon-button styling — so toggling dictation causes no + * vertical shift. + */ + +import React from 'react'; + +import { Icon } from '@/components/icon/Icon'; +import { useI18n } from '@/lib/i18n'; +import { useThemeSystem } from '@/contexts/useThemeSystem'; +import { cn } from '@/lib/utils'; +import { runtimeFetch } from '@/lib/runtime-fetch'; +import { useDictation } from '@/hooks/useDictation'; +import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { useConfigStore } from '@/stores/useConfigStore'; +import { useUIStore } from '@/stores/useUIStore'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; + +interface ComposerDictationProps { + radius?: number | string; + isMobile: boolean; + footerIconButtonClass: string; + footerPaddingClass: string; + iconSizeClass: string; + sendIconSizeClass: string; + disabled?: boolean; + onInsert: (text: string) => void; + onInsertAndSend: (text: string) => void; +} + +const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${String(secs).padStart(2, '0')}`; +}; + +const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => { + const { currentTheme } = useThemeSystem(); + return ( +