Skip to content

Commit f19c4e9

Browse files
committed
feat(chat): add native composer dictation
1 parent b4adde0 commit f19c4e9

4 files changed

Lines changed: 224 additions & 2 deletions

File tree

src/features/workspaces/components/ai-chat/AiChatPromptInput.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Bug, Plus } from "lucide-react";
1+
import { Bug, Mic, Plus } from "lucide-react";
22
import { lazy, Suspense, useRef, useState } from "react";
33

44
import {
@@ -24,6 +24,7 @@ import {
2424
} from "#/features/workspaces/components/ai-chat/constants";
2525
import type { AiChatModelId, AiChatStatus } from "#/features/workspaces/components/ai-chat/types";
2626
import { useAiChatAttachmentIntake } from "#/features/workspaces/components/ai-chat/useAiChatAttachmentIntake";
27+
import { useAiChatDictation } from "#/features/workspaces/components/ai-chat/useAiChatDictation";
2728
import { useTypeToFocusPrompt } from "#/features/workspaces/components/ai-chat/useTypeToFocusPrompt";
2829
import { WorkspaceFileIntakeReviewDialog } from "#/features/workspaces/components/WorkspaceFileIntakeReviewDialog";
2930
import { useWorkspaceFileUpload } from "#/features/workspaces/components/WorkspaceFileUploadProvider";
@@ -98,6 +99,7 @@ export default function AiChatPromptInput({
9899
const [input, setInput] = useState("");
99100
const [isInspectorOpen, setIsInspectorOpen] = useState(false);
100101
const textareaRef = useRef<HTMLTextAreaElement>(null);
102+
const dictation = useAiChatDictation({ input, setInput });
101103
const draftFiles = useWorkspaceAiComposerDraftFiles(context.workspaceId);
102104
const attachmentsReady =
103105
draftFiles.length === 0 || draftFiles.every((file) => file.status === "ready");
@@ -140,6 +142,7 @@ export default function AiChatPromptInput({
140142
return false;
141143
}
142144

145+
dictation.cancel();
143146
setInput("");
144147
return true;
145148
};
@@ -165,6 +168,7 @@ export default function AiChatPromptInput({
165168
<PromptInputTextarea
166169
ref={textareaRef}
167170
name="message"
171+
readOnly={dictation.isActive}
168172
value={input}
169173
placeholder="Ask anything"
170174
onChange={(event) => setInput(event.currentTarget.value)}
@@ -193,6 +197,21 @@ export default function AiChatPromptInput({
193197
</PromptInputTools>
194198

195199
<WorkspaceToolbarGroup className="ml-auto">
200+
{dictation.isSupported ? (
201+
<WorkspaceToolbarIconButton
202+
aria-label={dictation.isActive ? "Stop dictation" : "Start dictation"}
203+
aria-pressed={dictation.isActive}
204+
className={cn(
205+
"rounded-full",
206+
dictation.isActive &&
207+
"bg-destructive/10 text-destructive hover:bg-destructive/20 hover:text-destructive",
208+
)}
209+
disabled={!canType && !dictation.isActive}
210+
onClick={dictation.toggle}
211+
>
212+
<Mic className={dictation.isListening ? "ai-dictation-mic-pulse" : undefined} />
213+
</WorkspaceToolbarIconButton>
214+
) : null}
196215
<AiChatPromptSubmit
197216
attachmentsReady={attachmentsReady}
198217
input={input}
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2+
import { toast } from "sonner";
3+
4+
type BrowserSpeechRecognition = {
5+
continuous: boolean;
6+
interimResults: boolean;
7+
lang: string;
8+
onend: (() => void) | null;
9+
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null;
10+
onnomatch: (() => void) | null;
11+
onresult: ((event: SpeechRecognitionEvent) => void) | null;
12+
onstart: (() => void) | null;
13+
abort: () => void;
14+
start: () => void;
15+
stop: () => void;
16+
};
17+
18+
type SpeechRecognitionConstructor = new () => BrowserSpeechRecognition;
19+
type DictationPhase = "idle" | "starting" | "listening" | "stopping";
20+
21+
const subscribeToSpeechRecognition = () => () => {};
22+
const getSpeechRecognitionSupport = () => Boolean(getSpeechRecognitionConstructor());
23+
const getServerSpeechRecognitionSupport = () => false;
24+
25+
const DICTATION_ERROR_MESSAGES: Partial<Record<SpeechRecognitionErrorCode, string>> = {
26+
"audio-capture": "No microphone was found.",
27+
"language-not-supported": "Dictation does not support your browser language.",
28+
network: "Dictation lost its connection. Try again.",
29+
"no-speech": "No speech was detected. Try again.",
30+
"not-allowed": "Allow microphone access in your browser to use dictation.",
31+
"service-not-allowed": "Dictation is unavailable in this browser.",
32+
};
33+
34+
function getSpeechRecognitionConstructor(): SpeechRecognitionConstructor | null {
35+
if (typeof window === "undefined") {
36+
return null;
37+
}
38+
39+
const speechWindow = window as Window & {
40+
SpeechRecognition?: SpeechRecognitionConstructor;
41+
webkitSpeechRecognition?: SpeechRecognitionConstructor;
42+
};
43+
44+
return speechWindow.SpeechRecognition ?? speechWindow.webkitSpeechRecognition ?? null;
45+
}
46+
47+
function appendTranscript(input: string, transcript: string) {
48+
const spokenText = transcript.trimStart();
49+
if (!spokenText) {
50+
return input;
51+
}
52+
53+
const separator = input && !/\s$/.test(input) ? " " : "";
54+
return `${input}${separator}${spokenText}`;
55+
}
56+
57+
function readTranscript(results: SpeechRecognitionResultList) {
58+
let transcript = "";
59+
60+
for (let index = 0; index < results.length; index += 1) {
61+
transcript += results[index]?.[0]?.transcript ?? "";
62+
}
63+
64+
return transcript;
65+
}
66+
67+
export function useAiChatDictation({
68+
input,
69+
setInput,
70+
}: {
71+
input: string;
72+
setInput: (input: string) => void;
73+
}) {
74+
const [phase, setPhase] = useState<DictationPhase>("idle");
75+
const [isUnavailable, setIsUnavailable] = useState(false);
76+
const recognitionRef = useRef<BrowserSpeechRecognition | null>(null);
77+
const browserSupportsSpeechRecognition = useSyncExternalStore(
78+
subscribeToSpeechRecognition,
79+
getSpeechRecognitionSupport,
80+
getServerSpeechRecognitionSupport,
81+
);
82+
const isSupported = browserSupportsSpeechRecognition && !isUnavailable;
83+
84+
useEffect(() => {
85+
return () => {
86+
const recognition = recognitionRef.current;
87+
recognitionRef.current = null;
88+
recognition?.abort();
89+
};
90+
}, []);
91+
92+
const cancel = useCallback(() => {
93+
const recognition = recognitionRef.current;
94+
recognitionRef.current = null;
95+
setPhase("idle");
96+
recognition?.abort();
97+
}, []);
98+
99+
const stop = useCallback(() => {
100+
const recognition = recognitionRef.current;
101+
if (!recognition) {
102+
return;
103+
}
104+
105+
if (phase !== "listening") {
106+
cancel();
107+
return;
108+
}
109+
110+
setPhase("stopping");
111+
recognition.stop();
112+
}, [cancel, phase]);
113+
114+
const start = useCallback(() => {
115+
const Recognition = getSpeechRecognitionConstructor();
116+
if (!Recognition || recognitionRef.current) {
117+
return;
118+
}
119+
120+
const recognition = new Recognition();
121+
const inputBeforeDictation = input;
122+
const finish = () => {
123+
if (recognitionRef.current !== recognition) {
124+
return false;
125+
}
126+
127+
recognitionRef.current = null;
128+
setPhase("idle");
129+
return true;
130+
};
131+
132+
recognition.continuous = true;
133+
recognition.interimResults = true;
134+
recognition.lang = navigator.language;
135+
recognition.onstart = () => {
136+
if (recognitionRef.current === recognition) {
137+
setPhase("listening");
138+
}
139+
};
140+
recognition.onresult = (event) => {
141+
if (recognitionRef.current === recognition) {
142+
setInput(appendTranscript(inputBeforeDictation, readTranscript(event.results)));
143+
}
144+
};
145+
recognition.onnomatch = () => {
146+
if (recognitionRef.current === recognition) {
147+
toast.error("Could not recognize that. Try again.");
148+
}
149+
};
150+
recognition.onerror = (event) => {
151+
if (!finish()) {
152+
return;
153+
}
154+
155+
if (event.error === "language-not-supported" || event.error === "service-not-allowed") {
156+
setIsUnavailable(true);
157+
}
158+
159+
const message = DICTATION_ERROR_MESSAGES[event.error];
160+
if (message) {
161+
toast.error(message);
162+
}
163+
};
164+
recognition.onend = () => {
165+
finish();
166+
};
167+
168+
recognitionRef.current = recognition;
169+
setPhase("starting");
170+
171+
try {
172+
recognition.start();
173+
} catch {
174+
finish();
175+
toast.error("Dictation could not start. Try again.");
176+
}
177+
}, [input, setInput]);
178+
179+
const isActive = phase !== "idle";
180+
181+
return {
182+
cancel,
183+
isActive,
184+
isListening: phase === "listening",
185+
isSupported,
186+
toggle: isActive ? stop : start,
187+
};
188+
}

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function withSecurityHeaders(response: Response) {
9494
headers.set("X-Content-Type-Options", "nosniff");
9595
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
9696
headers.set("X-Frame-Options", "DENY");
97-
headers.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
97+
headers.set("Permissions-Policy", "camera=(), microphone=(self), geolocation=()");
9898

9999
if (isProduction) {
100100
headers.set("Strict-Transport-Security", "max-age=63072000; includeSubDomains; preload");

src/styles.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@
172172
--shimmer-color: color-mix(in oklch, var(--foreground) 55%, transparent);
173173
}
174174

175+
.ai-dictation-mic-pulse {
176+
animation: ai-dictation-mic-pulse 1.4s ease-in-out infinite;
177+
}
178+
175179
.collaboration-cursor-float {
176180
animation: collaboration-cursor-float 3.6s ease-in-out infinite;
177181
}
@@ -191,6 +195,17 @@
191195
pointer-events: none;
192196
}
193197

198+
@keyframes ai-dictation-mic-pulse {
199+
0%,
200+
100% {
201+
transform: scale(1);
202+
}
203+
204+
50% {
205+
transform: scale(1.04);
206+
}
207+
}
208+
194209
@keyframes collaboration-cursor-float {
195210
0%,
196211
100% {

0 commit comments

Comments
 (0)