mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 11:25:50 -06:00
refactor(ui): dictation v2 — comet edge, scrolling wave, live transcript, stop/send, bench axis
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user