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
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T02:50:44Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: 192a2a69ff8a183f
raw_hash: 4c35effb9b4cdea0
vouched: 2026-10-16T22:11:30Z
body_hash: 3ecd8d58953b3ffd
raw_hash: 8d76c6254cce3b7d
vouched: 2026-10-16T22:22:52Z
lines: 99-282
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T12:44:15Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: 192a2a69ff8a183f
raw_hash: 4c35effb9b4cdea0
vouched: 2026-10-16T22:11:30Z
body_hash: 3ecd8d58953b3ffd
raw_hash: 8d76c6254cce3b7d
vouched: 2026-10-16T22:22:52Z
lines: 101-290
---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ anchor: function Composer
created: 2026-10-15T14:16:28Z
norm: '1'
sig: 6e0e6c5c8afbd01a
body_hash: 192a2a69ff8a183f
raw_hash: 4c35effb9b4cdea0
vouched: 2026-10-16T22:11:30Z
body_hash: 3ecd8d58953b3ffd
raw_hash: 8d76c6254cce3b7d
vouched: 2026-10-16T22:22:52Z
lines: 104-326
---

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53CZ2S58V1DTJKB64V1SVRD
anchor: '@file'
created: 2026-10-16T22:22:49Z
norm: '1'
sig: eb0482a0ddee93a6
body_hash: de216f5680cfb1c1
raw_hash: 6c0b5799010be95c
lines: 1-42
---

WKWebView dictation needs NSMicrophoneUsageDescription and NSSpeechRecognitionUsageDescription in Info.plist, or macOS ends the app on first use
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M53CZ2RRXDYDPZWZ8AKNMFH0
anchor: function useDictation
created: 2026-10-16T22:22:49Z
norm: '1'
sig: de94e6fe0d9626d6
body_hash: 78e3734a05619b3c
raw_hash: 85cbb253122dfd32
lines: 28-51
---

deactivating aborts and detaches handlers, so no result lands after Stop or Send moves the editor on
1 change: 1 addition & 0 deletions apps/app/src/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@ export function Composer(
accessory={accessory}
scope={scope}
tools={!phone}
mic
modes={blocked ? BLOCKED_MODES : MODES}
mode={mode}
onModeChange={changeMode}
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/electrobun.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,14 @@ export default {
codesign: false,
notarize: false,
createDmg: false,
// Electrobun writes these usage strings into Info.plist; macOS ends the app when the composer's
// dictation asks for the microphone or speech recognition without them. Signing uses
// native/entitlements.plist, which carries the hardened-runtime audio-input entitlement.
entitlements: {
"com.apple.security.device.audio-input": "Ace listens while you dictate a message.",
"com.apple.security.personal-information.speech-recognition":
"Ace turns your dictation into message text.",
},
},
},
release: { generatePatch: false },
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/native/entitlements.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
<key>com.apple.security.cs.allow-jit</key><true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key><true/>
<key>com.apple.security.cs.disable-library-validation</key><true/>
<key>com.apple.security.device.audio-input</key><true/>
</dict></plist>
25 changes: 20 additions & 5 deletions packages/ui/src/components/composer/chat-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type { MarkType, Node as ProseMirrorNode, NodeType } from "prosemirror-mo
import { TextSelection } from "prosemirror-state";
import type { EditorView } from "prosemirror-view";

import { canDictate, useDictation } from "../../hooks/use-dictation";
import { useDraft } from "../../hooks/use-draft";
import { useHistory } from "../../hooks/use-history";
import { useMic } from "../../hooks/use-mic";
Expand Down Expand Up @@ -177,7 +178,7 @@ type Props = {
agents?: Agent[];
/** Overrides the active mode's placeholder. */
placeholder?: string;
/** Enables the microphone + recording UI. Defaults to false. */
/** Enables dictation where the runtime can transcribe speech. Defaults to false. */
mic?: boolean;
mode?: string;
onModeChange?: (mode: string) => void;
Expand Down Expand Up @@ -598,7 +599,7 @@ function useComposer({
models,
agents,
placeholder,
mic = false,
mic: dictation = false,
mode,
onModeChange,
model,
Expand Down Expand Up @@ -673,7 +674,15 @@ function useComposer({
let draft = useDraft(scope);
let restoredMode = useRef(draft.defaultValue ? curMode : undefined);
let history = useHistory(scope);
let mic = dictation && canDictate;
let analyser = useMic(mic && recording);
let dictated = useRef({ from: 0, size: 0 });
useDictation(mic && recording, (text) => {
let v = view.current!;
let { from, size } = dictated.current;
v.dispatch(v.state.tr.insertText(text, from, from + size));
dictated.current.size = text.length;
}, () => setRecording(false));

let view = useRef<EditorView | null>(null);
let file = useRef<HTMLInputElement | null>(null);
Expand Down Expand Up @@ -957,9 +966,15 @@ function useComposer({
v.focus();
}

function record() {
dictated.current = { from: view.current!.state.selection.from, size: 0 };
setRecording(true);
}

function send() {
let v = view.current;
if (!v) return;
setRecording(false);
let text = v.state.doc.textContent;
let sentMode = modeRef.current;
let mode = latest.current.modes.find(item => item.id === sentMode);
Expand Down Expand Up @@ -1183,6 +1198,7 @@ function useComposer({
empty,
filled,
planning,
record,
setRecording,
cancel,
link,
Expand Down Expand Up @@ -1635,6 +1651,7 @@ function ComposerSend({ state }: { state: ComposerState }) {
submitBusy,
submitLabel,
planning,
record,
setRecording,
send,
sendable,
Expand All @@ -1650,9 +1667,7 @@ function ComposerSend({ state }: { state: ComposerState }) {
busyLabel={submitLabel}
plan={planning}
className="relative z-10"
onRecord={mic
? () => setRecording(true)
: undefined}
onRecord={mic ? record : undefined}
onStop={mic ? () => setRecording(false) : undefined}
onSend={send}
disabled={!sendable && (!mic || busy || !canSend)}
Expand Down
53 changes: 53 additions & 0 deletions packages/ui/src/hooks/use-dictation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";

type Recognition = {
continuous: boolean;
interimResults: boolean;
lang: string;
onresult: ((event: { results: ArrayLike<ArrayLike<{ transcript: string }>> }) => void) | null;
onend: (() => void) | null;
start(): void;
abort(): void;
};

// WebKit (Safari and the desktop app's WKWebView) and Chromium ship only the prefixed constructor.
const Speech = typeof window === "undefined"
? undefined
: (window as unknown as { webkitSpeechRecognition?: new() => Recognition })
.webkitSpeechRecognition;

/** Whether this runtime can transcribe speech. */
const canDictate = Boolean(Speech);

/**
* Transcribe the microphone while `active`. `onText` receives the whole session's transcript,
* interim words included, each time it changes; `onEnd` runs when recognition stops on its own
* (silence, error, or a denied permission). Deactivating aborts, so the last interim words stand
* and no late result lands after the caller has moved on.
*/
function useDictation(active: boolean, onText: (text: string) => void, onEnd: () => void) {
let handlers = useRef({ onText, onEnd });
handlers.current = { onText, onEnd };

useEffect(() => {
if (!active || !Speech) return;
let recognition = new Speech();
recognition.continuous = true;
recognition.interimResults = true;
recognition.lang = navigator.language;
recognition.onresult = ({ results }) => {
let text = "";
for (let i = 0; i < results.length; i++) text += results[i]![0]!.transcript;
handlers.current.onText(text);
};
recognition.onend = () => handlers.current.onEnd();
recognition.start();
return () => {
recognition.onresult = null;
recognition.onend = null;
recognition.abort();
};
}, [active]);
}

export { canDictate, useDictation };