Merge pull request #157 from cynarAI/claude/composer-mic-send-toggle-jbg7wv

Composer: mic sits in the send slot until there is something to send
This commit is contained in:
cynarAI 2026-10-05 15:38:32 +02:00 committed by GitHub
commit 568d37c48c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 68 additions and 213 deletions

View File

@ -78,7 +78,7 @@ test("sidebar Now and Recent surface active and terminal runs", async ({ page },
await captureActivitySidebar(page, testInfo, "58-activity-now");
await page.getByRole("button", { name: "Stop", exact: true }).click();
await expect(page.getByRole("button", { name: "Send" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({ timeout: 30_000 });
await page.reload();
await expect(activityToggle).toHaveAttribute("aria-pressed", "true");

View File

@ -195,14 +195,23 @@ test("chat composer controls are vertically centered", async ({ page }) => {
await signup(page, `composer-layout-${stamp}@rakazo.test`, "password12", "Composer Layout");
await completeOnboarding(page);
const centers = await page.getByTestId("composer-bar").evaluate((composer) =>
["Add", "Message", "Voice", "Send"].map((label) => {
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
if (!element) throw new Error(`Missing composer control: ${label}`);
const box = element.getBoundingClientRect();
return box.top + box.height / 2;
}),
);
const bar = page.getByTestId("composer-bar");
const centersOf = (labels: string[]) =>
bar.evaluate(
(composer, controls) =>
controls.map((label) => {
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
if (!element) throw new Error(`Missing composer control: ${label}`);
const box = element.getBoundingClientRect();
return box.top + box.height / 2;
}),
labels,
);
const idle = await centersOf(["Add", "Message", "Voice"]);
await page.getByRole("combobox", { name: "Message" }).fill("hello");
const typing = await centersOf(["Add", "Message", "Send"]);
const centers = [...idle, ...typing];
expect(Math.max(...centers) - Math.min(...centers)).toBeLessThanOrEqual(1);
});

View File

@ -378,12 +378,14 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
await expect(page.getByText("Messages sent now guide the next turn.")).toHaveCount(0);
await expect(page.getByText(/^Steer /)).toHaveCount(0);
await expect(composer).toHaveAttribute("placeholder", "Message…");
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeVisible();
// An empty draft offers voice in the send slot; typing turns it into Send.
const voice = page
.getByTestId("composer-bar")
.getByRole("button", { name: "Voice", exact: true });
await expect(voice).toBeVisible();
await expect(page.getByRole("button", { name: "Send", exact: true })).toHaveCount(0);
await composer.fill("Use the newer report and keep the answer short.");
await page.keyboard.press("Tab");
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toBeFocused();
await expect(voice).toHaveCount(0);
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeFocused();
await page.keyboard.press("Enter");
@ -393,7 +395,7 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "14-active-bot-work");
await page.setViewportSize({ width: 390, height: 844 });
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeVisible();
await expect(voice).toBeVisible();
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
await expect(composer).toHaveAttribute("placeholder", "Message…");
await captureScreenshot(page, testInfo, "14-active-bot-work-mobile");
@ -414,14 +416,13 @@ test("sign-in, spawn, and stop work in the shell", async ({ page }, testInfo) =>
});
await page.getByRole("button", { name: "Stop", exact: true }).click();
await stopRequestStarted;
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeDisabled();
await expect(voice).toBeVisible();
await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeDisabled();
releaseStopRequest();
// Idle Send stays disabled with an empty draft; wait for Stop to leave instead.
await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0, {
timeout: 30_000,
});
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeVisible();
await expect(voice).toBeVisible();
await page.context().clearCookies();
await page.goto("/sign-in");

View File

@ -45,7 +45,7 @@ test("shows peer chips in transcript and opens view-only peer chat", async ({ pa
)
.toBe(true);
await expect(page.getByRole("button", { name: "Send" })).toBeVisible({ timeout: 60_000 });
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({ timeout: 60_000 });
const transcript = page.getByTestId("transcript");
const chip = transcript

View File

@ -150,7 +150,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
await routine.click();
await page.getByRole("button", { name: "Test run" }).click();
await expect(page.getByText(/routine-run-now-ok/i).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("button", { name: "Send" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({ timeout: 30_000 });
await captureScreenshot(page, testInfo, "34-routine-run-completed");
await page.reload();

View File

@ -54,6 +54,10 @@ test("voice settings connect a key, speak a reply, and open a call", async ({ pa
const composer = page.getByPlaceholder(/Message/);
await composer.fill("say hello");
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toHaveCount(0);
await captureScreenshot(page, testInfo, "voice-composer-typing");
await page.keyboard.press("Enter");
// A reply can render more than one text bubble; speak the latest one.
const speakReply = page.getByRole("button", { name: "Speak this reply" }).last();

View File

@ -24,7 +24,6 @@ import {
import { personaIdlePrompt, personaThinkingLines } from "../../lib/voice-persona-copy";
import { VoiceAwarenessStrip } from "../voice/VoiceAwarenessStrip";
import { StageHero } from "./StageHero";
import { StageVoiceEntry } from "./StageVoiceEntry";
import { TextStageCanvas } from "./TextStageCanvas";
export function OoStage({
@ -37,8 +36,6 @@ export function OoStage({
snapshot,
running,
hasMessages,
voiceReady = false,
onStartVoice,
}: {
botId: string;
botName: string;
@ -49,8 +46,6 @@ export function OoStage({
snapshot: ThreadSnapshot | null;
running: boolean;
hasMessages: boolean;
voiceReady?: boolean;
onStartVoice?: () => void;
}) {
const { t } = useLingui();
const [reducedMotion, setReducedMotion] = useState(false);
@ -242,13 +237,6 @@ export function OoStage({
>
{subtitle}
</p>
{onStartVoice ? (
<StageVoiceEntry
voiceReady={voiceReady}
botColor={botColor}
onStartVoice={onStartVoice}
/>
) : null}
</>
) : (
<p className="mt-1 w-full max-w-xl truncate text-center text-ui-caption text-muted-foreground">
@ -306,14 +294,10 @@ export function OoGroupStage({
groupName,
snapshot,
enrichMember,
voiceReady = false,
onStartVoice,
}: {
groupName?: string;
snapshot: ThreadSnapshot | null;
enrichMember?: (botId: string) => Pick<GroupStageMember, "title" | "description"> | undefined;
voiceReady?: boolean;
onStartVoice?: () => void;
}) {
const { t } = useLingui();
const [reducedMotion, setReducedMotion] = useState(false);
@ -552,14 +536,6 @@ export function OoGroupStage({
{subtitle}
</p>
)}
{onStartVoice && !hasMessages ? (
<StageVoiceEntry
voiceReady={voiceReady}
botColor={speaker?.color}
onStartVoice={onStartVoice}
/>
) : null}
</div>
</section>
);

View File

@ -1,43 +0,0 @@
import { Trans, useLingui } from "@lingui/react/macro";
import { Button } from "@rakazo/ui-web";
import { Mic } from "lucide-react";
import { VoiceAuroraRim } from "../voice/VoiceAurora";
export function StageVoiceEntry({
voiceReady,
botColor,
onStartVoice,
}: {
voiceReady: boolean;
botColor?: string;
onStartVoice: () => void;
}) {
const { t } = useLingui();
return (
<div className="mt-5 flex w-full max-w-md flex-col items-center gap-2">
<Button
type="button"
variant="outline"
onClick={onStartVoice}
data-testid="stage-voice-primary"
aria-label={voiceReady ? t`Start talking` : t`Set up voice`}
title={voiceReady ? t`Start talking` : t`Set up voice`}
className={`tactile-press h-control-lg w-full max-w-sm gap-2.5 rounded-full px-6 text-ui-body font-semibold transition-all ${
voiceReady
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
: "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10"
}`}
>
{voiceReady ? (
<VoiceAuroraRim botColor={botColor} className="inset-x-[12%] h-0.5 blur-[3px]" />
) : null}
<Mic strokeWidth={2} className="size-icon-lg shrink-0" aria-hidden="true" />
{voiceReady ? <Trans>Start talking</Trans> : <Trans>Set up voice</Trans>}
</Button>
<p className="text-center text-ui-caption text-muted-foreground">
<Trans>Voice first — transcript below</Trans>
</p>
</div>
);
}

View File

@ -4567,7 +4567,6 @@ msgstr "Servername"
msgid "Server URL"
msgstr "Server-URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr "Stimme einrichten"
@ -4822,10 +4821,6 @@ msgstr "Chat starten"
msgid "Start recording"
msgstr "Aufnahme starten"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr "Sprechen starten"
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr "Starte die Unterhaltung — {activeName} steht oben auf der Bühne."
@ -5346,10 +5341,6 @@ msgstr "Stimme"
msgid "Voice \"{0}\" not found."
msgstr "Stimme „{0}“ nicht gefunden."
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr "Primär per Stimme — Transkript unten"
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr "Die Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren."

View File

@ -4567,7 +4567,6 @@ msgstr "Server name"
msgid "Server URL"
msgstr "Server URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr "Set up voice"
@ -4822,10 +4821,6 @@ msgstr "Start chat"
msgid "Start recording"
msgstr "Start recording"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr "Start talking"
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr "Start the conversation — {activeName} is on stage above."
@ -5346,10 +5341,6 @@ msgstr "Voice"
msgid "Voice \"{0}\" not found."
msgstr "Voice \"{0}\" not found."
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr "Voice first — transcript below"
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr "Voice is not available yet. You can turn it on later in settings."

View File

@ -4567,7 +4567,6 @@ msgstr "Nombre del servidor"
msgid "Server URL"
msgstr "URL del servidor"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "Iniciar grabación"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "Voz"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -4567,7 +4567,6 @@ msgstr "सर्वर का नाम"
msgid "Server URL"
msgstr "सर्वर URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "रिकॉर्डिंग शुरू करें"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "आवाज़"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -4567,7 +4567,6 @@ msgstr "서버 이름"
msgid "Server URL"
msgstr "서버 URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "작업 녹화 시작"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "음성"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -4567,7 +4567,6 @@ msgstr "Nome do servidor"
msgid "Server URL"
msgstr "URL do servidor"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "Começar a gravar"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "Voz"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -4567,7 +4567,6 @@ msgstr "Имя сервера"
msgid "Server URL"
msgstr "URL-адрес сервера"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "Начать запись"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "Голос"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -4567,7 +4567,6 @@ msgstr "Sunucu adı"
msgid "Server URL"
msgstr "Sunucu URL'si"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "Kaydı başlat"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "Ses"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -4567,7 +4567,6 @@ msgstr "服务器名称"
msgid "Server URL"
msgstr "服务器 URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4822,10 +4821,6 @@ msgstr ""
msgid "Start recording"
msgstr "开始录制"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr ""
#: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above."
msgstr ""
@ -5346,10 +5341,6 @@ msgstr "语音"
msgid "Voice \"{0}\" not found."
msgstr ""
#: src/components/stage/StageVoiceEntry.tsx
msgid "Voice first — transcript below"
msgstr ""
#: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings."
msgstr ""

View File

@ -3323,8 +3323,6 @@ export function ShellPage() {
snapshot={activeSnapshot}
running={transcriptRunning}
hasMessages={transcriptMessages.length > 0}
voiceReady={Boolean(voiceStatus?.ready)}
onStartVoice={startVoiceEncounter}
/>
) : null}
{!callOpen && inGroup ? (
@ -3332,8 +3330,6 @@ export function ShellPage() {
groupName={activeGroup?.name ?? activeSnapshot?.groupName}
snapshot={activeSnapshot}
enrichMember={enrichStageMember}
voiceReady={Boolean(voiceStatus?.ready)}
onStartVoice={startVoiceEncounter}
/>
) : null}
<Transcript

View File

@ -135,6 +135,38 @@ export const Composer = memo(function Composer({
selectedSkill !== null ||
selectedMentions.length > 0 ||
pendingAttachments.length > 0;
// One slot, two jobs: an empty draft talks, a written one sends.
const voiceInSlot = Boolean(onVoice) && !canSend;
const primaryAction = voiceInSlot ? (
<Button
key="voice"
size="icon"
aria-label={t`Voice`}
title={voiceReady ? t`Start voice conversation` : t`Set up voice`}
disabled={disabled}
onClick={onVoice}
data-testid="composer-voice-primary"
className="tactile-press relative size-10 shrink-0 overflow-hidden rounded-full transition-all active:scale-90 animate-in fade-in zoom-in-75 duration-150 flex items-center justify-center cursor-pointer"
>
{voiceReady ? (
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[22%] h-0.5 blur-[3px]" />
) : null}
<Mic strokeWidth={2.2} className="size-icon-lg" />
</Button>
) : (
<Button
key="send"
size="icon"
aria-label={t`Send`}
disabled={!canSend || disabled}
onClick={send}
className={`tactile-press size-10 shrink-0 rounded-full transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 flex items-center justify-center cursor-pointer ${
onVoice ? "animate-in fade-in zoom-in-75 duration-150" : ""
}`}
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
);
useEffect(() => {
if (!runError || !runErrorId) return;
@ -743,40 +775,9 @@ export const Composer = memo(function Composer({
className="max-h-36 min-h-[28px] min-w-[8rem] flex-1 resize-none overflow-y-auto bg-transparent py-1 text-[16.5px] leading-[1.55] text-foreground outline-none placeholder:text-muted-foreground/75 disabled:opacity-40 font-normal"
/>
</div>
{onVoice ? (
<Button
variant="outline"
aria-label={t`Voice`}
title={voiceReady ? t`Start voice conversation` : t`Set up voice`}
disabled={disabled}
onClick={onVoice}
data-testid="composer-voice-primary"
className={`tactile-press h-control-lg shrink-0 gap-2 rounded-full px-4 sm:px-5 flex items-center justify-center cursor-pointer transition-all ${
voiceReady
? "relative overflow-hidden border-border bg-foreground/5 text-foreground hover:bg-foreground/10"
: "border-border bg-foreground/5 text-foreground/80 hover:bg-foreground/10"
}`}
>
{voiceReady ? (
<VoiceAuroraRim botColor={voiceColor} className="inset-x-[14%] h-0.5 blur-[3px]" />
) : null}
<Mic strokeWidth={1.9} className="size-icon-lg shrink-0" />
<span className="hidden text-ui-body font-semibold sm:inline">
<Trans>Voice</Trans>
</span>
</Button>
) : null}
{running ? (
<div className="flex items-center gap-2 shrink-0">
<Button
size="icon"
aria-label={t`Send`}
disabled={!canSend || disabled}
onClick={send}
className="tactile-press size-10 rounded-full transition-transform active:scale-90 flex items-center justify-center cursor-pointer"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
{primaryAction}
<Button
variant="outline"
size="icon"
@ -789,15 +790,7 @@ export const Composer = memo(function Composer({
</Button>
</div>
) : (
<Button
size="icon"
aria-label={t`Send`}
disabled={!canSend || disabled}
onClick={send}
className="tactile-press size-10 shrink-0 rounded-full transition-all active:scale-90 disabled:bg-foreground/[0.08] disabled:text-foreground/20 flex items-center justify-center cursor-pointer"
>
<ArrowUp strokeWidth={2.6} className="size-icon-lg" />
</Button>
primaryAction
)}
</div>
</div>