refactor(ui): dictation v2 — comet edge, scrolling wave, live transcript, stop/send, bench axis

This commit is contained in:
vyctorbrzezowski
2026-08-22 00:01:18 -03:00
parent 462667468d
commit 46ddec7c8c
9 changed files with 191 additions and 110 deletions
+10
View File
@@ -65,3 +65,13 @@ Lane: composer selects (worktree `composer-bench`). Bench files intocados.
- i18n: `+discardDictation` ("Cancel dictation"), `-dictationReleaseToInsert` (sem usos); baseline+verify ok.
- Limpeza: CSS morto `chat-send-btn__dictation-time` e `agent-chat__dictation-copy` removidos.
- Verificação: DOM do estado dictating injetado no bench (:5230) — screenshots dark/light inspecionados (`/tmp/herdr/dict-*.png`); vitest dictation/composer suites: mesmas 5 falhas pré-existentes do checkpoint, 72 pass; tsgo:ui sem erros nos arquivos tocados; stylelint só com os 3 erros pré-existentes.
## Voice mode v2 (iteração)
- Borda: sem tint no frame; só o cometa accent (rastro longo atrás, cabeça brilhante, escuro à frente) com `corner-shape: superellipse(1.5)` no ::after.
- Wave: modo `scroll` no `openclaw-microphone-activity` — ring buffer de níveis, 48 barras finas, história correndo direita→esquerda (waveform streamando).
- Texto: partial do ditado streama direto no textarea (preview via `insertComposerDictation` na seleção capturada); placeholder oculto no modo; commit real no stop/release.
- Ações: **stop** (mesmo elemento do mic; commita o texto no draft) + **send** (commita e envia via `finishActive().then(onSend)`); sem descarte por botão — Esc é o único discard. Status vira sr-only. `finishActive` agora retorna Promise.
- i18n: `dictationStop` no lugar de `discardDictation`; `insertDictation` removido (sem usos).
- Bench: novo eixo `dictate` (off/connecting/recording/finalizing) — stub de controller semeado em `getChatComposerState("composer-bench").dictation` (marcado `benchStub`), níveis animados + partial roteirizado; linha "Dictation" no painel (`scripts/control-ui-mock-dev.ts` — requer restart do mock server para o botão aparecer; via URL `?bench={"dictate":"recording"}` já funciona).
- Verificado em :5230 via URL param: screenshots inspecionados (wave rolando, texto streamando, stop/send, borda). tsgo pulado a pedido (fase de iteração).
+10
View File
@@ -457,6 +457,16 @@ const composerBenchHtml = `<!doctype html>
["camera-error", "Camera error"],
["error", "Error"],
])}</div><div class="composer-bench__row" data-bench-voice-unavailable hidden><span>Voice</span><span class="composer-bench__muted">Chat only</span></div>` +
composerBenchDisclosure(
"Dictation",
"dictate",
[
["off", "Off"],
["connecting", "Starting"],
["recording", "Recording"],
["finalizing", "Finishing"],
],
) +
composerBenchDisclosure(
"Microphone",
"voiceInput",
+1 -2
View File
@@ -5974,11 +5974,10 @@ export const en: TranslationMap = {
dictationFinalizing: "Finishing dictation…",
dictationFinalizationTimedOut:
"Dictation stopped before the last partial transcript could be finalized.",
insertDictation: "Insert dictation",
dictationProviderUnavailable:
"No transcription provider is configured for dictation. Choose one in Settings to dictate.",
dictationRecording: "Recording {elapsed}",
discardDictation: "Cancel dictation",
dictationStop: "Stop dictation",
realtimeTalkRequiresMicrophone: "Realtime voice input requires browser microphone access.",
selectedMicrophoneUnavailable:
"The selected microphone is unavailable. Choose another input or System default.",
@@ -232,7 +232,7 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
const label = finalizing
? t("chat.composer.dictationFinalizing")
: active
? t("chat.composer.discardDictation")
? t("chat.composer.dictationStop")
: (props.idleLabel ?? t("chat.composer.startVoiceInput"));
const tooltip =
props.dictation && !(active || finalizing) ? t("chat.composer.voiceGestureHint") : label;
@@ -249,7 +249,9 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
@pointerdown=${(event: PointerEvent) => props.onDictationPointerDown?.(event)}
@click=${(event: MouseEvent) => {
if (active) {
props.dictation?.cancelActive();
// Stop = keep the words: capture ends and the transcript lands
// in the draft. The only discard path is Escape, by design.
void props.dictation?.finishActive();
return;
}
if (props.dictation) {
@@ -266,12 +268,7 @@ export function renderComposerVoiceButton(props: ComposerVoiceButtonProps) {
${finalizing
? icons.loader
: active
? html`
${icons.x}
<span class="chat-send-btn__dictation-cancel-label"
>${t("common.cancel")}</span
>
`
? icons.stop
: html`
${icons.mic}
<span class="agent-chat__control-label">${label}</span>
@@ -362,13 +359,9 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
hasComposedContent ? activeRunActionDescription : t("chat.composer.emptyHint"),
activeRunActionLabel,
);
const dictationConfirmAction = props.dictation?.active
const dictationSendAction = props.dictation?.active
? html`
<span
class="agent-chat__dictation-label"
role="status"
aria-live="polite"
aria-atomic="true"
<span class="sr-only" role="status" aria-live="polite" aria-atomic="true"
>${props.dictation.finalizing
? t("chat.composer.dictationFinalizing")
: props.dictation.connecting
@@ -377,16 +370,20 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
elapsed: props.dictation.elapsed,
})}</span
>
<openclaw-tooltip .content=${t("chat.composer.insertDictation")}>
<openclaw-tooltip .content=${t("chat.runControls.sendMessage")}>
<button
class="chat-send-btn chat-send-btn--dictation-confirm"
class="chat-send-btn chat-send-btn--send chat-send-btn--dictation-send"
type="button"
@pointerdown=${props.onPrimaryActionPointerDown}
@click=${() => props.dictation?.finishActive()}
@click=${(event: MouseEvent) => {
// Send = stop capturing, let the transcript commit into the
// draft, then hand the composed message to the normal send path.
void props.dictation?.finishActive().then(() => props.onSend(event));
}}
?disabled=${props.dictation.finalizing}
aria-label=${t("chat.composer.insertDictation")}
aria-label=${t("chat.runControls.sendMessage")}
>
${icons.check}
${icons.arrowUp}
</button>
</openclaw-tooltip>
`
@@ -458,7 +455,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
: html`
${voiceControl}
${props.dictation?.active
? dictationConfirmAction
? dictationSendAction
: props.canAbort
? hasComposedContent
? sendAction
@@ -8,6 +8,7 @@ import { renderSessionProgressCard } from "../../../components/session-progress-
import { t } from "../../../i18n/index.ts";
import { detectTextDirection } from "../../../lib/text-direction.ts";
import type { ComposerDictationController } from "../composer-dictation.ts";
import { insertComposerDictation } from "../composer-dictation.ts";
import {
handleChatAttachmentPaste,
renderAttachmentPreview,
@@ -236,6 +237,20 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
? html`<div class="agent-chat__composer-underlaps">${composerAlerts}${offlineHint}</div>`
: nothing;
const skillDraftOverlay = renderSkillDraftOverlay(visibleDraft);
// Dictation streams into the editor: the live partial previews at the spot
// the commit will use (the selection captured at hold start), so the operator
// reads their words landing in the draft rather than in a side status row.
// The textarea is read-only for the whole capture, so the preview swap never
// fights operator edits; release performs the real insert.
const dictationPreviewDraft =
dictation?.active && dictation.partial
? insertComposerDictation(
visibleDraft,
dictation.partial,
state.dictationSelection?.start ?? visibleDraft.length,
state.dictationSelection?.end ?? visibleDraft.length,
).value
: visibleDraft;
const queue = renderChatQueue({
queue: props.queue,
@@ -361,14 +376,10 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
${renderMicrophoneActivity({
status: dictation.connecting ? "connecting" : "listening",
inputLevel: dictation.inputLevel,
bars: 36,
bars: 48,
mode: "scroll",
})}
</span>
${dictation.partial
? html`<span class="agent-chat__dictation-partial"
>${dictation.partial}</span
>`
: nothing}
</div>
`
: nothing}
@@ -424,8 +435,8 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
<textarea
${ref(state.textareaRef ?? undefined)}
class=${skillDraftOverlay === nothing ? "" : "agent-chat__composer-textarea--rich"}
.value=${visibleDraft}
dir=${detectTextDirection(visibleDraft)}
.value=${dictationPreviewDraft}
dir=${detectTextDirection(dictationPreviewDraft)}
?disabled=${!canCompose}
?readonly=${dictation?.locksComposer === true}
aria-autocomplete="list"
@@ -462,7 +473,7 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
}
}}
aria-label=${placeholder}
placeholder=${placeholder}
placeholder=${dictation?.active ? "" : placeholder}
rows="1"
></textarea>
${skillDraftOverlay}
@@ -27,6 +27,9 @@ class MicrophoneActivityElement extends HTMLElement {
private levelSignal: RealtimeTalkLevelSignal | undefined;
private unsubscribe: (() => void) | null = null;
private gains: number[] = BAR_GAINS;
// `mode="scroll"`: bars render a right-to-left history of levels (newest on
// the right) so speech reads as a streaming waveform instead of one meter.
private history: number[] | null = null;
set signal(signal: RealtimeTalkLevelSignal | undefined) {
if (signal === this.levelSignal) {
@@ -58,6 +61,7 @@ class MicrophoneActivityElement extends HTMLElement {
this.gains = activityBarGains(
Number.isInteger(requested) && requested > 0 ? requested : BAR_GAINS.length,
);
this.history = this.getAttribute("mode") === "scroll" ? this.gains.map(() => 0) : null;
for (const [index] of this.gains.entries()) {
const bar = document.createElement("span");
bar.className = "agent-chat__voice-activity-bar";
@@ -74,12 +78,15 @@ class MicrophoneActivityElement extends HTMLElement {
private renderLevel(level: number): void {
this.dataset.level = String(level);
if (this.history) {
this.history.push(level);
this.history.shift();
}
for (const [index, bar] of [...this.children].entries()) {
const gain = this.gains[index] ?? 1;
(bar as HTMLElement).style.setProperty(
"--talk-bar-scale",
String(0.18 + level * gain * 0.82),
);
const scale = this.history
? 0.12 + (this.history[index] ?? 0) * 0.88
: 0.18 + level * (this.gains[index] ?? 1) * 0.82;
(bar as HTMLElement).style.setProperty("--talk-bar-scale", String(scale));
}
}
}
@@ -115,6 +122,7 @@ type MicrophoneActivityProps = {
status?: RealtimeTalkStatus;
inputLevel?: RealtimeTalkLevelSignal;
bars?: number;
mode?: "scroll";
};
// Class names and data attributes are asserted by the talk e2e suite; the
@@ -127,6 +135,7 @@ export function renderMicrophoneActivity(props: MicrophoneActivityProps): Templa
data-status=${activeStatus(props.status)}
data-source="microphone"
bars=${ifDefined(props.bars)}
mode=${ifDefined(props.mode)}
aria-hidden="true"
.signal=${props.inputLevel ?? EMPTY_LEVEL_SIGNAL}
>
+4 -2
View File
@@ -449,8 +449,10 @@ export class ComposerDictationController {
return `${minutes}:${seconds}`;
}
finishActive(): void {
void this.stop({ commit: true });
// Returns the stop promise so callers can sequence work (e.g. send) after
// the transcript lands in the draft.
finishActive(): Promise<void> {
return this.stop({ commit: true });
}
cancelActive(): void {
+30 -72
View File
@@ -3041,14 +3041,11 @@ button.chat-pr__diff {
}
/* Dictation is a temporary mode of the writing surface, not another card in
it. A narrow moving edge in the brand accent signals live capture while the
editor and its transcript stay stable underneath. Accent, not danger, on
purpose: capture is the product's signature action, not a destructive one. */
it. The frame itself stays quiet — only a brand-accent light with a soft
trail orbits the edge to signal live capture. */
.agent-chat__input--dictating,
.agent-chat__input--dictating:focus-within {
isolation: isolate;
border-color: color-mix(in srgb, var(--accent) 24%, var(--chat-composer-hairline));
box-shadow: 0 4px 18px rgb(0 0 0 / 6%);
}
.agent-chat__input--dictating::after {
@@ -3058,13 +3055,19 @@ button.chat-pr__diff {
inset: -1px;
padding: 1px;
border-radius: inherit;
/* corner-shape does not travel with border-radius: inherit; restate it so
the light hugs the composer's superellipse instead of a rounder ghost. */
corner-shape: superellipse(1.5);
/* Comet, not a ring: a long fading tail behind a bright head, dark again
right in front of it, so the light reads as travelling, not as a tinted
border. */
background: conic-gradient(
from var(--chat-dictation-edge-angle),
transparent 0deg 220deg,
color-mix(in srgb, var(--accent) 18%, transparent) 256deg,
color-mix(in srgb, var(--accent) 88%, white) 294deg,
color-mix(in srgb, var(--accent) 30%, transparent) 326deg,
transparent 360deg
transparent 0deg 200deg,
color-mix(in srgb, var(--accent) 10%, transparent) 252deg,
color-mix(in srgb, var(--accent) 45%, transparent) 286deg,
color-mix(in srgb, var(--accent) 92%, white) 294deg,
transparent 301deg 360deg
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
@@ -3498,9 +3501,9 @@ button.chat-pr__diff {
padding: 12px 14px 0;
}
/* Capture mode pares the footer back to the two decisions that matter: cancel
the recording or accept the transcript. Draft content and attachments remain
in place so entering the mode never discards operator context. */
/* Capture mode pares the footer back to the two decisions that matter: stop
dictating (transcript stays in the draft) or send it. Draft content and
attachments remain in place so entering the mode never discards context. */
.agent-chat__input--dictating .agent-chat__composer-lead,
.agent-chat__input--dictating .agent-chat__composer-meta,
.agent-chat__input--dictating .agent-chat__composer-controls,
@@ -3516,17 +3519,6 @@ button.chat-pr__diff {
grid-column: 1;
}
/* Capture bar: cancel pill on the leading edge, live status centered, accept
on the trailing edge — one row, mirroring the reference voice UIs. */
.agent-chat__input--dictating .agent-chat__composer-trail,
.agent-chat__input--dictating .agent-chat__composer-actions {
width: 100%;
}
.agent-chat__input--dictating .agent-chat__composer-actions {
justify-content: space-between;
}
/* Keep settings, split view, and run status together in the footer's middle
track. The track is sized to content, so this group shrinks before it ever
pushes mic and send off the right edge. */
@@ -4951,16 +4943,14 @@ button.chat-pr__diff {
user-select: none;
}
/* Cancel pill while capturing. Same element as the idle mic button — swapping
it would release pointer capture mid-hold — so the mode only restyles it. */
/* Capture button while dictating. Same element as the idle mic — swapping it
would release pointer capture mid-hold — so the mode only restyles it.
Stop = keep the transcript; Escape is the only discard. */
.chat-send-btn.chat-send-btn--dictating {
display: inline-flex;
align-items: center;
gap: 6px;
width: auto;
width: var(--chat-composer-send-size);
min-width: var(--chat-composer-send-size);
height: var(--chat-composer-send-size);
padding: 0 12px;
padding: 5px;
border-radius: var(--radius-full);
color: var(--text-strong);
background: color-mix(in srgb, var(--text) 10%, transparent);
@@ -4978,15 +4968,8 @@ button.chat-pr__diff {
.chat-send-btn--dictating > svg {
width: 14px;
height: 14px;
fill: none;
stroke: currentColor;
stroke-width: 2px;
}
.chat-send-btn__dictation-cancel-label {
font-size: 13px;
font-weight: 600;
line-height: 1;
fill: currentColor;
stroke: none;
}
.chat-send-btn--dictating .agent-chat__voice-activity-bar {
@@ -4994,30 +4977,18 @@ button.chat-pr__diff {
box-shadow: 0 0 5px color-mix(in srgb, var(--accent) 30%, transparent);
}
/* Accept keeps the send button's station and the brand accent: committing the
transcript is this mode's primary action. */
.agent-chat__input .chat-send-btn--dictation-confirm {
background: var(--accent);
color: var(--accent-foreground);
}
.agent-chat__input .chat-send-btn--dictation-confirm:hover:not(:disabled) {
background: var(--accent-hover);
}
.agent-chat__dictation-status {
display: grid;
min-width: 0;
align-items: center;
justify-items: center;
gap: 6px;
width: 100%;
color: var(--accent);
font-size: 12px;
}
/* Full-width strip: the level meter is the room's main signal while capturing,
so it spans the editor instead of hiding in a pill. */
/* Full-width strip: the scrolling level history is the room's main signal
while capturing, so it spans the editor instead of hiding in a pill. */
.agent-chat__dictation-wave {
display: flex;
align-items: center;
@@ -5027,11 +4998,16 @@ button.chat-pr__diff {
}
.agent-chat__dictation-wave .agent-chat__voice-activity {
gap: 4px;
width: 100%;
height: 24px;
justify-content: space-between;
}
.agent-chat__dictation-wave .agent-chat__voice-activity-bar {
width: 2.5px;
height: 22px;
flex: 0 1 2.5px;
background: color-mix(in srgb, var(--accent) 88%, var(--text));
box-shadow: 0 0 5px color-mix(in srgb, var(--accent) 24%, transparent);
}
@@ -5045,24 +5021,6 @@ button.chat-pr__diff {
box-shadow: none;
}
.agent-chat__dictation-label {
min-width: 0;
flex: 1 1 auto;
color: var(--muted);
font-size: 13px;
font-variant-numeric: tabular-nums;
text-align: center;
}
.agent-chat__dictation-partial {
max-width: 100%;
min-width: 0;
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-chat__video-preview {
position: absolute;
left: 50%;
+85
View File
@@ -16,6 +16,8 @@ import type { SessionToolOverrides } from "../lib/sessions/patch.ts";
import { renderExecApprovalCard } from "../components/exec-approval-card.ts";
import { renderBackgroundTasksStatusRow } from "../pages/chat/components/chat-background-tasks-status.ts";
import { renderChatComposer, resetChatComposerState } from "../pages/chat/components/chat-composer.ts";
import { getChatComposerState } from "../pages/chat/components/chat-composer-state.ts";
import type { ComposerDictationController } from "../pages/chat/composer-dictation.ts";
import { renderChatModelControls } from "../pages/chat/components/chat-model-controls.ts";
import { renderChatPermissionPicker } from "../pages/chat/components/chat-permission-picker.ts";
import { renderChatPullRequests } from "../pages/chat/components/chat-pull-requests.ts";
@@ -94,6 +96,7 @@ type BenchState = {
| "camera-pending"
| "camera-error"
| "error";
dictate: "off" | "connecting" | "recording" | "finalizing";
voiceInput:
| "available"
| "unsupported"
@@ -165,6 +168,7 @@ const defaults: BenchState = {
capabilities: "attachments",
toolOverrides: null,
voice: "off",
dictate: "off",
voiceInput: "available",
content: "multiline",
attachments: "none",
@@ -514,6 +518,71 @@ let newProjectQuery = "";
let newWorktree = true;
const realtimeTalkLevel = new RealtimeTalkLevelSignal();
realtimeTalkLevel.set(0.42);
// Dictation simulation: the bench cannot run a real capture session (no
// gateway client, getUserMedia is mocked to fail), so it pre-seeds the
// composer's state slot with a stub controller. `renderChatComposer` keeps it
// (`state.dictation ??=`), and the real template renders the real mode —
// border light, scrolling wave, streaming partial, stop/send — end to end.
const benchDictationLevel = new RealtimeTalkLevelSignal();
const benchDictationScript =
"Tailor the composer spacing so every control stays aligned while I dictate this sentence".split(
" ",
);
let benchDictationTimer: number | null = null;
let benchDictationPartial = "";
let benchDictationTicks = 0;
function benchDictationController(): ComposerDictationController {
const phase = state.dictate;
const stub = {
active: phase !== "off",
connecting: phase === "connecting",
finalizing: phase === "finalizing",
locksComposer: phase !== "off",
partial: phase === "recording" ? benchDictationPartial : "",
elapsed: `0:${String(Math.floor(benchDictationTicks / 10) % 60).padStart(2, "0")}`,
inputLevel: benchDictationLevel,
finishActive: () => {
publishState({ dictate: "off" });
return Promise.resolve();
},
cancelActive: () => publishState({ dictate: "off" }),
handleClick: () => {},
handleContextMenu: () => {},
handlePointerDown: () => {},
update: () => {},
dispose: () => {},
};
return stub as unknown as ComposerDictationController;
}
function syncBenchDictation(): void {
const recording = state.surface === "chat" && state.dictate === "recording";
if (recording && benchDictationTimer === null) {
benchDictationTimer = window.setInterval(() => {
benchDictationTicks += 1;
const speaking = Math.sin(benchDictationTicks / 4) > -0.4;
benchDictationLevel.set(
speaking ? 0.2 + Math.random() * 0.75 : Math.random() * 0.06,
);
const spokenWords = Math.floor(benchDictationTicks / 4);
const nextPartial = benchDictationScript.slice(0, spokenWords).join(" ");
if (nextPartial !== benchDictationPartial) {
benchDictationPartial = nextPartial;
renderBench();
}
}, 100);
} else if (!recording && benchDictationTimer !== null) {
window.clearInterval(benchDictationTimer);
benchDictationTimer = null;
benchDictationLevel.set(0);
}
if (state.dictate === "off") {
benchDictationPartial = "";
benchDictationTicks = 0;
}
}
const benchCameraStream = new MediaStream();
const newSessionTextarea = new NewSessionComposerTextareaController();
const attachmentDraft = new NewSessionAttachmentDraft(renderBench, mirrorAttachmentState);
@@ -1090,6 +1159,19 @@ function renderChatSurface(sessionList: SessionsListResult) {
onAction: () => publishState({ inset: "none" }),
}
: undefined;
// Seed the composer's dictation slot before render; `??=` inside keeps the
// stub alive across rerenders. Clearing only our own stub (marked) lets the
// real controller path own the slot whenever the axis is off.
const composerState = getChatComposerState("composer-bench");
if (state.dictate !== "off") {
const stub = benchDictationController() as ComposerDictationController & {
benchStub?: boolean;
};
stub.benchStub = true;
composerState.dictation = stub;
} else if ((composerState.dictation as { benchStub?: boolean } | null)?.benchStub) {
composerState.dictation = null;
}
const composer = renderChatComposer({
paneId: "composer-bench",
sessionKey: "agent:main:main",
@@ -1589,6 +1671,7 @@ function renderBench(): void {
if (!stage) {
return;
}
syncBenchDictation();
const sessionList = sessions();
stage.style.setProperty("--composer-bench-width", `${state.width}px`);
stage.dataset.composerBenchSurface = state.surface;
@@ -1660,6 +1743,7 @@ function commitState(next: Partial<BenchState>, markScenarioCustom = true): void
if (
next.neighbor !== undefined ||
next.voice !== undefined ||
next.dictate !== undefined ||
next.voiceInput !== undefined
) {
state.surface = "chat";
@@ -1748,6 +1832,7 @@ function syncControls(): void {
"usage",
"neighbor",
"voice",
"dictate",
"voiceInput",
]);
const newOnlyAxes = new Set<keyof BenchState>(["visibility", "newAction"]);