+
+ {error && (
+
+ {error}
+
+ )}
+ {!error && recording && (
+ <>
+
+ {/* Bars flex to the row's free width — the owner's "fill up
+ the whole middle section" ask: pitch is whatever space
+ exists between session data and the model control. */}
+
+ {levels.map((v, i) => (
+
+ ))}
+
+ {interim && (
+
+ {interim}
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/components/home.tsx b/components/home.tsx
index e7a5c1e..9005c75 100644
--- a/components/home.tsx
+++ b/components/home.tsx
@@ -5,6 +5,7 @@ import { useRouter } from "next/navigation";
import { CornerDownLeftIcon } from "lucide-react";
import { GraphView } from "@/components/graph-view";
import { GraphContextControl, effectiveContextTokens } from "@/components/graph-context-control";
+import { DictationBar, useDictation } from "@/components/dictation";
import { SidebarToggle } from "@/components/sidebar";
import { StatsBar } from "@/components/stats-bar";
import type { ChatSettings, TurnUsage } from "@/src/core";
@@ -18,6 +19,11 @@ import type { ChatSettings, TurnUsage } from "@/src/core";
export function Home() {
const router = useRouter();
const [topic, setTopic] = useState("");
+ // Voice dictation (STT wave, 2026-09-17): finals append at the end of the
+ // topic draft; the strip lives in the stats bar's middle slot.
+ const dictation = useDictation({
+ append: (t) => setTopic((v) => (v ? v.replace(/\s+$/, "") + " " + t : t)),
+ });
const [busy, setBusy] = useState(false);
// The model+thinking selection made pre-chat — lifted from the StatsBar's
// ModelControl, carried to the chat POST then PUT-on-create (the model
@@ -97,6 +103,12 @@ export function Home() {
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => {
+ // Esc ends dictation (the dictate decisions: click-again or Esc).
+ if (e.key === "Escape" && dictation.active) {
+ e.preventDefault();
+ dictation.stop();
+ return;
+ }
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void start();
@@ -121,6 +133,7 @@ export function Home() {
budget (session-data slot), while the RIGHT keeps the model +
thinking control (deferred save). */}
@@ -110,9 +116,11 @@ export function DraftShell({
onStop={() => {}}
canSend={canSend}
autoFocus
+ onEscape={dictation.active ? dictation.stop : undefined}
/>
}
turns={[]}
chatId=""
settings={settings}
diff --git a/components/stats-bar.tsx b/components/stats-bar.tsx
index 2520325..8fc5f55 100644
--- a/components/stats-bar.tsx
+++ b/components/stats-bar.tsx
@@ -33,6 +33,7 @@ export function StatsBar({
settings,
onSettingsSaved,
leftSlot,
+ dictationSlot,
}: {
turns: TurnUsage[];
chatId: string;
@@ -41,6 +42,11 @@ export function StatsBar({
/** Pre-chat only: replaces the (empty) session-data summary — home and
* the /chat/new draft mount the knowledge-context slider here. */
leftSlot?: ReactNode;
+ /** The dictation strip (STT wave, 2026-09-17): the row's middle is dead
+ * space between the session data and the model control — the mic
+ * button lives there, and while recording the meter unfurls to fill
+ * whatever width the row has (owner placement). */
+ dictationSlot?: ReactNode;
}) {
const [open, setOpen] = useState(false);
// Same dropdown discipline as the model control (owner, df4433f treatment,
@@ -155,6 +161,12 @@ export function StatsBar({
)}
)}
+ {/* The middle slot: dictation (owner, 2026-09-17). flex-1 lets the
+ meter expand into the row's free width; justify-center keeps the
+ lone idle mic button visually centered. */}
+ {dictationSlot && (
+