Put voice in the composer's send slot until there is something to send

An empty draft shows the mic where the send arrow sits; it opens the
voice conversation as the separate Voice button did. As soon as the draft
holds text, a chip or an attachment, the slot turns back into Send. This
frees the width the extra Voice pill took on small screens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XcoERsCTbvjqDzE8qC82Wo
This commit is contained in:
Claude 2026-10-05 08:52:06 +00:00
parent 404a1d1782
commit f3e438444a
No known key found for this signature in database
7 changed files with 68 additions and 61 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,15 +195,24 @@ 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 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

@ -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>