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/pages/shell/composer.tsx b/apps/web/src/pages/shell/composer.tsx index df10a7a..6f21915 100644 --- a/apps/web/src/pages/shell/composer.tsx +++ b/apps/web/src/pages/shell/composer.tsx @@ -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 ? ( + + ) : ( + + ); 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}
) : ( - + primaryAction )}