diff --git a/apps/web/e2e/activity-list.spec.ts b/apps/web/e2e/activity-list.spec.ts
index 05ee392..1c7e360 100644
--- a/apps/web/e2e/activity-list.spec.ts
+++ b/apps/web/e2e/activity-list.spec.ts
@@ -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");
diff --git a/apps/web/e2e/bot-organization.spec.ts b/apps/web/e2e/bot-organization.spec.ts
index 510cb52..c32a4cd 100644
--- a/apps/web/e2e/bot-organization.spec.ts
+++ b/apps/web/e2e/bot-organization.spec.ts
@@ -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(`[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(`[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);
});
diff --git a/apps/web/e2e/golden.spec.ts b/apps/web/e2e/golden.spec.ts
index 17ace91..447a582 100644
--- a/apps/web/e2e/golden.spec.ts
+++ b/apps/web/e2e/golden.spec.ts
@@ -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");
diff --git a/apps/web/e2e/peer-messages.spec.ts b/apps/web/e2e/peer-messages.spec.ts
index d77da20..8852b55 100644
--- a/apps/web/e2e/peer-messages.spec.ts
+++ b/apps/web/e2e/peer-messages.spec.ts
@@ -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
diff --git a/apps/web/e2e/routine-execution.spec.ts b/apps/web/e2e/routine-execution.spec.ts
index 7f24174..478d054 100644
--- a/apps/web/e2e/routine-execution.spec.ts
+++ b/apps/web/e2e/routine-execution.spec.ts
@@ -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();
diff --git a/apps/web/e2e/voice.spec.ts b/apps/web/e2e/voice.spec.ts
index ee2295f..94e275e 100644
--- a/apps/web/e2e/voice.spec.ts
+++ b/apps/web/e2e/voice.spec.ts
@@ -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();
diff --git a/apps/web/src/components/stage/OoStage.tsx b/apps/web/src/components/stage/OoStage.tsx
index c6b5d23..5cbdb9e 100644
--- a/apps/web/src/components/stage/OoStage.tsx
+++ b/apps/web/src/components/stage/OoStage.tsx
@@ -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}
- {onStartVoice ? (
-
- ) : null}
>
) : (
@@ -306,14 +294,10 @@ export function OoGroupStage({
groupName,
snapshot,
enrichMember,
- voiceReady = false,
- onStartVoice,
}: {
groupName?: string;
snapshot: ThreadSnapshot | null;
enrichMember?: (botId: string) => Pick | undefined;
- voiceReady?: boolean;
- onStartVoice?: () => void;
}) {
const { t } = useLingui();
const [reducedMotion, setReducedMotion] = useState(false);
@@ -552,14 +536,6 @@ export function OoGroupStage({
{subtitle}
)}
-
- {onStartVoice && !hasMessages ? (
-
- ) : null}
);
diff --git a/apps/web/src/components/stage/StageVoiceEntry.tsx b/apps/web/src/components/stage/StageVoiceEntry.tsx
deleted file mode 100644
index dd32f8b..0000000
--- a/apps/web/src/components/stage/StageVoiceEntry.tsx
+++ /dev/null
@@ -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 (
-
-
-
- Voice first — transcript below
-
-
- );
-}
diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po
index b497e5b..d42f5fa 100644
--- a/apps/web/src/locales/de/messages.po
+++ b/apps/web/src/locales/de/messages.po
@@ -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."
diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po
index b1d5bef..4f56b03 100644
--- a/apps/web/src/locales/en/messages.po
+++ b/apps/web/src/locales/en/messages.po
@@ -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."
diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po
index 69cc77e..42f6f72 100644
--- a/apps/web/src/locales/es/messages.po
+++ b/apps/web/src/locales/es/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po
index e5e0917..4a29ccb 100644
--- a/apps/web/src/locales/hi/messages.po
+++ b/apps/web/src/locales/hi/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po
index 5d80cb6..8eeedda 100644
--- a/apps/web/src/locales/ko/messages.po
+++ b/apps/web/src/locales/ko/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po
index 48cf91b..0a46646 100644
--- a/apps/web/src/locales/pt-BR/messages.po
+++ b/apps/web/src/locales/pt-BR/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po
index 426b602..5c2559d 100644
--- a/apps/web/src/locales/ru/messages.po
+++ b/apps/web/src/locales/ru/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po
index bbc9266..8212fde 100644
--- a/apps/web/src/locales/tr/messages.po
+++ b/apps/web/src/locales/tr/messages.po
@@ -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 ""
diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po
index 4d1f650..31ec6e5 100644
--- a/apps/web/src/locales/zh-CN/messages.po
+++ b/apps/web/src/locales/zh-CN/messages.po
@@ -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 ""
diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx
index e023af9..1fcfc84 100644
--- a/apps/web/src/pages/Shell.tsx
+++ b/apps/web/src/pages/Shell.tsx
@@ -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}
0 ||
pendingAttachments.length > 0;
+ // One slot, two jobs: an empty draft talks, a written one sends.
+ const voiceInSlot = Boolean(onVoice) && !canSend;
+ const primaryAction = voiceInSlot ? (
+
+ ) : (
+
+ );
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"
/>
- {onVoice ? (
-
- ) : null}
{running ? (
) : (
-
+ primaryAction
)}