feat: dictation round 1 — primary-input routing, no interims, optimistic meter, wide button
Owner's first live mic test (2026-09-17) drove four changes: - ONE primary transcription input at all times: while a quiz/ask question waits, finals land in its field (text mode → 'Your answer', select modes → 'Your thinking') via a module-level claim registry (useDictationTarget in dictation.tsx); the composer is the fallback. Esc in a question field also stops dictation (threaded through DockOverlay → QuestionDock). - No interim results: live words were distracting and shifted the meter. Params now match the pi dictate extension exactly (no interim_results, endpointing=300, no utterance_end_ms); the meter's width stays fixed. - Optimistic meter: the click enters the meter view immediately (Stop pill + meter within one frame, verified at 150ms in the dev shell); token mint and mic acquisition run concurrently behind it. - Wide idle button: the stats-bar middle is one full-width affordance — a quiet bordered 'Dictate' button when idle (failure text replaces the label), Stop pill + fixed meter while live. Live-verified in the Electron dev shell: wide button renders on home (167px) and chat (358px), meter + Stop appear at 150ms after click, flat 2px bars in silence, clean return to 'Dictate' on stop.
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { joinDictation, useDictationTarget } from "@/components/dictation";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
@@ -63,6 +64,9 @@ export interface QuestionDockProps {
|
||||
reveal?: boolean;
|
||||
onExpand: () => void;
|
||||
onSubmit: (output: QuestionAnswerData) => void;
|
||||
/** Escape in a question field stops dictation (STT wave) — threaded
|
||||
* from the page that owns the dictation hook. */
|
||||
onEscape?: () => void;
|
||||
/** Session phase at the question — scales the thinking field: probing
|
||||
* stays quick (one row), teaching invites dumping (three rows). */
|
||||
phase?: Phase3;
|
||||
@@ -76,6 +80,7 @@ export function QuestionDock({
|
||||
reveal = false,
|
||||
onExpand,
|
||||
onSubmit,
|
||||
onEscape,
|
||||
phase = "probe",
|
||||
}: QuestionDockProps) {
|
||||
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
|
||||
@@ -109,6 +114,7 @@ export function QuestionDock({
|
||||
input={parsed}
|
||||
phase={phase}
|
||||
onSubmit={onSubmit}
|
||||
onEscape={onEscape}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -189,6 +195,7 @@ function QuestionDockForm({
|
||||
input,
|
||||
phase = "probe",
|
||||
onSubmit,
|
||||
onEscape,
|
||||
}: Omit<QuestionDockProps, "expanded" | "onExpand">) {
|
||||
const kind = toolName === "quiz" ? ("quiz" as const) : ("ask" as const);
|
||||
|
||||
@@ -243,6 +250,22 @@ function QuestionDockForm({
|
||||
const [text, setText] = useState("");
|
||||
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
||||
|
||||
// The PRIMARY dictation input (owner, STT round 1): while a question
|
||||
// waits, dictated finals land HERE, never in the hidden composer — text
|
||||
// mode owns "Your answer", select modes own "Your thinking". The claim
|
||||
// is effect-scoped, so the question unmounting releases it and
|
||||
// dictation falls back to the composer automatically.
|
||||
useDictationTarget(
|
||||
useCallback(
|
||||
(t: string) => {
|
||||
if (qd.mode === "text") setText((v) => joinDictation(v, t));
|
||||
else setThinking((v) => joinDictation(v, t));
|
||||
},
|
||||
[qd.mode],
|
||||
),
|
||||
true,
|
||||
);
|
||||
|
||||
const toggle = (idx: number) => setSel(toggleOption(selectMode, sel, idx));
|
||||
|
||||
const canSubmit =
|
||||
@@ -420,6 +443,12 @@ function QuestionDockForm({
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation (the dictate decisions: click-again or Esc).
|
||||
if (e.key === "Escape" && onEscape) {
|
||||
e.preventDefault();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
// Chat convention: Enter submits, Shift+Enter is a newline;
|
||||
// ⌘/Ctrl+Enter also submits (universal across the app).
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey || !e.shiftKey)) {
|
||||
@@ -436,6 +465,12 @@ function QuestionDockForm({
|
||||
value={thinking}
|
||||
onChange={(e) => setThinking(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
// Esc ends dictation (the dictate decisions: click-again or Esc).
|
||||
if (e.key === "Escape" && onEscape) {
|
||||
e.preventDefault();
|
||||
onEscape();
|
||||
return;
|
||||
}
|
||||
// ⌘↵ on macOS, Ctrl↵ elsewhere — the Gmail convention; plain
|
||||
// Enter stays a newline (this field is for dumping).
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
|
||||
Reference in New Issue
Block a user