Merge main up to e55207f into claude/media-smart-cuts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-05 14:50:22 +00:00
commit a6d253ffae
No known key found for this signature in database
24 changed files with 114 additions and 271 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 captureActivitySidebar(page, testInfo, "58-activity-now");
await page.getByRole("button", { name: "Stop", exact: true }).click(); 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 page.reload();
await expect(activityToggle).toHaveAttribute("aria-pressed", "true"); 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 signup(page, `composer-layout-${stamp}@rakazo.test`, "password12", "Composer Layout");
await completeOnboarding(page); await completeOnboarding(page);
const centers = await page.getByTestId("composer-bar").evaluate((composer) => const bar = page.getByTestId("composer-bar");
["Add", "Message", "Voice", "Send"].map((label) => { const centersOf = (labels: string[]) =>
bar.evaluate(
(composer, controls) =>
controls.map((label) => {
const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`); const element = composer.querySelector<HTMLElement>(`[aria-label="${label}"]`);
if (!element) throw new Error(`Missing composer control: ${label}`); if (!element) throw new Error(`Missing composer control: ${label}`);
const box = element.getBoundingClientRect(); const box = element.getBoundingClientRect();
return box.top + box.height / 2; 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); 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("Messages sent now guide the next turn.")).toHaveCount(0);
await expect(page.getByText(/^Steer /)).toHaveCount(0); await expect(page.getByText(/^Steer /)).toHaveCount(0);
await expect(composer).toHaveAttribute("placeholder", "Message…"); 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 composer.fill("Use the newer report and keep the answer short.");
await page.keyboard.press("Tab"); await expect(voice).toHaveCount(0);
await expect(
page.getByTestId("composer-bar").getByRole("button", { name: "Voice", exact: true }),
).toBeFocused();
await page.keyboard.press("Tab"); await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeFocused(); await expect(page.getByRole("button", { name: "Send", exact: true })).toBeFocused();
await page.keyboard.press("Enter"); 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 expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "14-active-bot-work"); await captureScreenshot(page, testInfo, "14-active-bot-work");
await page.setViewportSize({ width: 390, height: 844 }); 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(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
await expect(composer).toHaveAttribute("placeholder", "Message…"); await expect(composer).toHaveAttribute("placeholder", "Message…");
await captureScreenshot(page, testInfo, "14-active-bot-work-mobile"); 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 page.getByRole("button", { name: "Stop", exact: true }).click();
await stopRequestStarted; 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(); await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeDisabled();
releaseStopRequest(); 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, { await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0, {
timeout: 30_000, timeout: 30_000,
}); });
await expect(page.getByRole("button", { name: "Send", exact: true })).toBeVisible(); await expect(voice).toBeVisible();
await page.context().clearCookies(); await page.context().clearCookies();
await page.goto("/sign-in"); 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); .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 transcript = page.getByTestId("transcript");
const chip = 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 routine.click();
await page.getByRole("button", { name: "Test run" }).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.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 captureScreenshot(page, testInfo, "34-routine-run-completed");
await page.reload(); await page.reload();

View File

@ -1,5 +1,5 @@
import { expect, test } from "@playwright/test"; import { expect, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, signup } from "./helpers"; import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
test("teach a task records interaction and saves a draft", async ({ page }, testInfo) => { test("teach a task records interaction and saves a draft", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
@ -17,11 +17,15 @@ test("teach a task records interaction and saves a draft", async ({ page }, test
await expect(sidePanel.getByRole("button", { name: "Take control" })).toHaveCount(0); await expect(sidePanel.getByRole("button", { name: "Take control" })).toHaveCount(0);
await expect(sidePanel.getByTestId("computer-more-button")).toHaveCount(0); await expect(sidePanel.getByTestId("computer-more-button")).toHaveCount(0);
await expect(sidePanel.getByTestId("computer-preview")).toBeVisible(); await expect(sidePanel.getByTestId("computer-preview")).toBeVisible();
// Showing the panel never boots the computer; its Open action is visible without hover.
const openButton = sidePanel.getByTestId("computer-preview-open");
await expect(openButton).toBeVisible();
const botId = activeBotId(page);
expect((await rpc<{ state: string }>(page, "computer/status", { botId })).state).toBe("stopped");
await captureScreenshot(page, testInfo, "teach-sidepanel-simple"); await captureScreenshot(page, testInfo, "teach-sidepanel-simple");
const preview = sidePanel.getByTestId("computer-preview"); const preview = sidePanel.getByTestId("computer-preview");
await preview.hover(); await preview.hover();
const openButton = sidePanel.getByTestId("computer-preview-open");
await expect(openButton).toBeVisible(); await expect(openButton).toBeVisible();
await expect(openButton.getByText("Open", { exact: true })).toBeVisible(); await expect(openButton.getByText("Open", { exact: true })).toBeVisible();
await captureScreenshot(page, testInfo, "teach-sidepanel-open-hover"); await captureScreenshot(page, testInfo, "teach-sidepanel-open-hover");

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/); const composer = page.getByPlaceholder(/Message/);
await composer.fill("say hello"); 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"); await page.keyboard.press("Enter");
// A reply can render more than one text bubble; speak the latest one. // A reply can render more than one text bubble; speak the latest one.
const speakReply = page.getByRole("button", { name: "Speak this reply" }).last(); 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 { personaIdlePrompt, personaThinkingLines } from "../../lib/voice-persona-copy";
import { VoiceAwarenessStrip } from "../voice/VoiceAwarenessStrip"; import { VoiceAwarenessStrip } from "../voice/VoiceAwarenessStrip";
import { StageHero } from "./StageHero"; import { StageHero } from "./StageHero";
import { StageVoiceEntry } from "./StageVoiceEntry";
import { TextStageCanvas } from "./TextStageCanvas"; import { TextStageCanvas } from "./TextStageCanvas";
export function OoStage({ export function OoStage({
@ -37,8 +36,6 @@ export function OoStage({
snapshot, snapshot,
running, running,
hasMessages, hasMessages,
voiceReady = false,
onStartVoice,
}: { }: {
botId: string; botId: string;
botName: string; botName: string;
@ -49,8 +46,6 @@ export function OoStage({
snapshot: ThreadSnapshot | null; snapshot: ThreadSnapshot | null;
running: boolean; running: boolean;
hasMessages: boolean; hasMessages: boolean;
voiceReady?: boolean;
onStartVoice?: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const [reducedMotion, setReducedMotion] = useState(false); const [reducedMotion, setReducedMotion] = useState(false);
@ -242,13 +237,6 @@ export function OoStage({
> >
{subtitle} {subtitle}
</p> </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"> <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, groupName,
snapshot, snapshot,
enrichMember, enrichMember,
voiceReady = false,
onStartVoice,
}: { }: {
groupName?: string; groupName?: string;
snapshot: ThreadSnapshot | null; snapshot: ThreadSnapshot | null;
enrichMember?: (botId: string) => Pick<GroupStageMember, "title" | "description"> | undefined; enrichMember?: (botId: string) => Pick<GroupStageMember, "title" | "description"> | undefined;
voiceReady?: boolean;
onStartVoice?: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
const [reducedMotion, setReducedMotion] = useState(false); const [reducedMotion, setReducedMotion] = useState(false);
@ -552,14 +536,6 @@ export function OoGroupStage({
{subtitle} {subtitle}
</p> </p>
)} )}
{onStartVoice && !hasMessages ? (
<StageVoiceEntry
voiceReady={voiceReady}
botColor={speaker?.color}
onStartVoice={onStartVoice}
/>
) : null}
</div> </div>
</section> </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

@ -9,9 +9,8 @@ import {
activeThreadRuns, activeThreadRuns,
applyThreadSendReceipt, applyThreadSendReceipt,
clearActiveThreadRuns, clearActiveThreadRuns,
computerPanelAutoBoot,
computerPanelAutoUsesBoot,
computerPanelNeedsMaintenance, computerPanelNeedsMaintenance,
computerPanelReconnects,
computerTakeoverBlocked, computerTakeoverBlocked,
isThreadSnapshotEvent, isThreadSnapshotEvent,
mergeThreadSnapshot, mergeThreadSnapshot,
@ -1513,25 +1512,19 @@ describe("computer event reduction", () => {
}); });
}); });
it("auto-boots stopped computers and recovers a running screen that has no URL", () => { it("reconnects only a running screen that has no URL and never boots on open", () => {
expect(computerPanelAutoBoot("stopped")).toBe("boot"); expect(computerPanelReconnects("stopped")).toBe(false);
expect(computerPanelAutoBoot("error")).toBe("boot"); expect(computerPanelReconnects("error")).toBe(false);
expect(computerPanelAutoBoot(undefined)).toBe("boot"); expect(computerPanelReconnects(undefined)).toBe(false);
expect(computerPanelAutoBoot("running", "https://screen.example")).toBe("wait"); expect(computerPanelReconnects("booting")).toBe(false);
expect(computerPanelAutoBoot("running", null)).toBe("recover-screen"); expect(computerPanelReconnects("suspended")).toBe(false);
expect(computerPanelAutoBoot("booting")).toBe("wait"); expect(computerPanelReconnects("running", "https://screen.example")).toBe(false);
expect(computerPanelAutoBoot("suspended")).toBe("wait"); expect(computerPanelReconnects("running", null)).toBe(true);
}); });
it("maps recover-screen to computer.boot, not computer.recover", () => { it("shows maintenance only for an errored computer that is not booting", () => {
expect(computerPanelAutoUsesBoot("recover-screen")).toBe(true);
expect(computerPanelAutoUsesBoot("boot")).toBe(true);
expect(computerPanelAutoUsesBoot("wait")).toBe(false);
});
it("shows maintenance only after a stopped or errored computer finishes booting", () => {
expect(computerPanelNeedsMaintenance("error", false)).toBe(true); expect(computerPanelNeedsMaintenance("error", false)).toBe(true);
expect(computerPanelNeedsMaintenance("stopped", false)).toBe(true); expect(computerPanelNeedsMaintenance("stopped", false)).toBe(false);
expect(computerPanelNeedsMaintenance("error", true)).toBe(false); expect(computerPanelNeedsMaintenance("error", true)).toBe(false);
expect(computerPanelNeedsMaintenance("running", false)).toBe(false); expect(computerPanelNeedsMaintenance("running", false)).toBe(false);
expect(computerPanelNeedsMaintenance(undefined, false)).toBe(false); expect(computerPanelNeedsMaintenance(undefined, false)).toBe(false);
@ -1541,7 +1534,7 @@ describe("computer event reduction", () => {
const panel = "computer"; const panel = "computer";
const booting = false; const booting = false;
const showInSidePanel = (computerOpen: boolean) => const showInSidePanel = (computerOpen: boolean) =>
panel === "computer" && !computerOpen && computerPanelNeedsMaintenance("stopped", booting); panel === "computer" && !computerOpen && computerPanelNeedsMaintenance("error", booting);
expect(showInSidePanel(false)).toBe(true); expect(showInSidePanel(false)).toBe(true);
expect(showInSidePanel(true)).toBe(false); expect(showInSidePanel(true)).toBe(false);

View File

@ -539,27 +539,23 @@ export function computerTakeoverBlocked(
return isActive(runStatus as RunStatus); return isActive(runStatus as RunStatus);
} }
export function computerPanelAutoBoot( /**
* Opening the side panel only reconnects a computer that already runs. Starting a stopped one
* is the user's choice (Open), because a boot is slow and heavy on the host.
* The reconnect must use computer.boot — never computer.recover (that destroys the sandbox).
*/
export function computerPanelReconnects(
state: ComputerStatus["state"] | undefined, state: ComputerStatus["state"] | undefined,
screenUrl?: string | null, screenUrl?: string | null,
): "boot" | "recover-screen" | "wait" {
if (state === "booting" || state === "suspended") return "wait";
if (state === "running") return screenUrl ? "wait" : "recover-screen";
return "boot";
}
/** Auto panel reconnect must use computer.boot — never computer.recover (that destroys the sandbox). */
export function computerPanelAutoUsesBoot(
action: ReturnType<typeof computerPanelAutoBoot>,
): boolean { ): boolean {
return action === "boot" || action === "recover-screen"; return state === "running" && !screenUrl;
} }
export function computerPanelNeedsMaintenance( export function computerPanelNeedsMaintenance(
state: ComputerStatus["state"] | undefined, state: ComputerStatus["state"] | undefined,
booting: boolean, booting: boolean,
): boolean { ): boolean {
return !booting && (state === "error" || state === "stopped"); return !booting && state === "error";
} }
export function reduceComputerStatus( export function reduceComputerStatus(

View File

@ -4579,7 +4579,6 @@ msgstr "Servername"
msgid "Server URL" msgid "Server URL"
msgstr "Server-URL" msgstr "Server-URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
msgid "Set up voice" msgid "Set up voice"
msgstr "Stimme einrichten" msgstr "Stimme einrichten"
@ -4834,10 +4833,6 @@ msgstr "Chat starten"
msgid "Start recording" msgid "Start recording"
msgstr "Aufnahme starten" msgstr "Aufnahme starten"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr "Sprechen starten"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above." msgid "Start the conversation — {activeName} is on stage above."
msgstr "Starte die Unterhaltung — {activeName} steht oben auf der Bühne." msgstr "Starte die Unterhaltung — {activeName} steht oben auf der Bühne."
@ -5378,10 +5373,6 @@ msgstr "Stimme"
msgid "Voice \"{0}\" not found." msgid "Voice \"{0}\" not found."
msgstr "Stimme „{0}“ nicht gefunden." 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 #: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings." 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." msgstr "Die Stimme ist noch nicht verfügbar. Du kannst sie später in den Einstellungen aktivieren."

View File

@ -4579,7 +4579,6 @@ msgstr "Server name"
msgid "Server URL" msgid "Server URL"
msgstr "Server URL" msgstr "Server URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx #: src/pages/shell/composer.tsx
msgid "Set up voice" msgid "Set up voice"
msgstr "Set up voice" msgstr "Set up voice"
@ -4834,10 +4833,6 @@ msgstr "Start chat"
msgid "Start recording" msgid "Start recording"
msgstr "Start recording" msgstr "Start recording"
#: src/components/stage/StageVoiceEntry.tsx
msgid "Start talking"
msgstr "Start talking"
#: src/pages/shell/transcript.tsx #: src/pages/shell/transcript.tsx
msgid "Start the conversation — {activeName} is on stage above." msgid "Start the conversation — {activeName} is on stage above."
msgstr "Start the conversation — {activeName} is on stage above." msgstr "Start the conversation — {activeName} is on stage above."
@ -5378,10 +5373,6 @@ msgstr "Voice"
msgid "Voice \"{0}\" not found." msgid "Voice \"{0}\" not found."
msgstr "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 #: src/components/onboarding/OnboardingVoiceStep.tsx
msgid "Voice is not available yet. You can turn it on later in settings." 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." msgstr "Voice is not available yet. You can turn it on later in settings."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -124,8 +124,7 @@ import {
activeThreadRuns, activeThreadRuns,
applyThreadSendReceipt, applyThreadSendReceipt,
clearActiveThreadRuns, clearActiveThreadRuns,
computerPanelAutoBoot, computerPanelReconnects,
computerPanelAutoUsesBoot,
computerTakeoverBlocked, computerTakeoverBlocked,
isComputerStatusEvent, isComputerStatusEvent,
prependThreadMessagePage, prependThreadMessagePage,
@ -551,7 +550,6 @@ export function ShellPage() {
outputTokens: number; outputTokens: number;
runs: number; runs: number;
} | null>(null); } | null>(null);
const autoBooted = useRef<string | null>(null);
const routineSavePending = useRef(false); const routineSavePending = useRef(false);
const webhookSecretProvisionRef = useRef(new Map<string, Promise<string>>()); const webhookSecretProvisionRef = useRef(new Map<string, Promise<string>>());
const ensureWebhookSecret = (botId: string) => const ensureWebhookSecret = (botId: string) =>
@ -2503,11 +2501,7 @@ export function ShellPage() {
} }
useEffect(() => { useEffect(() => {
if (panel !== "computer") { if (panel !== "computer" || !active) return;
autoBooted.current = null;
return;
}
if (!active) return;
const botId = active.id; const botId = active.id;
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
@ -2518,19 +2512,10 @@ export function ShellPage() {
const state = snap?.computer?.state; const state = snap?.computer?.state;
const screen = state === "running" ? await refreshComputerScreen(botId) : null; const screen = state === "running" ? await refreshComputerScreen(botId) : null;
if (cancelled || activeBotId.current !== botId) return; if (cancelled || activeBotId.current !== botId) return;
const action = computerPanelAutoBoot(state, screen); if (!computerPanelReconnects(state, screen)) return;
if (action === "wait") { await bootComputer({ takeControl: false, overlay: false, force: true }).catch(
if (state === "running") autoBooted.current = botId; () => undefined,
return; );
}
if (action === "boot" && autoBooted.current === botId) return;
autoBooted.current = botId;
if (!computerPanelAutoUsesBoot(action)) return;
await bootComputer({
takeControl: false,
overlay: action === "boot",
force: true,
}).catch(() => undefined);
})(); })();
return () => { return () => {
cancelled = true; cancelled = true;
@ -3323,8 +3308,6 @@ export function ShellPage() {
snapshot={activeSnapshot} snapshot={activeSnapshot}
running={transcriptRunning} running={transcriptRunning}
hasMessages={transcriptMessages.length > 0} hasMessages={transcriptMessages.length > 0}
voiceReady={Boolean(voiceStatus?.ready)}
onStartVoice={startVoiceEncounter}
/> />
) : null} ) : null}
{!callOpen && inGroup ? ( {!callOpen && inGroup ? (
@ -3332,8 +3315,6 @@ export function ShellPage() {
groupName={activeGroup?.name ?? activeSnapshot?.groupName} groupName={activeGroup?.name ?? activeSnapshot?.groupName}
snapshot={activeSnapshot} snapshot={activeSnapshot}
enrichMember={enrichStageMember} enrichMember={enrichStageMember}
voiceReady={Boolean(voiceStatus?.ready)}
onStartVoice={startVoiceEncounter}
/> />
) : null} ) : null}
<Transcript <Transcript

View File

@ -135,6 +135,38 @@ export const Composer = memo(function Composer({
selectedSkill !== null || selectedSkill !== null ||
selectedMentions.length > 0 || selectedMentions.length > 0 ||
pendingAttachments.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(() => { useEffect(() => {
if (!runError || !runErrorId) return; 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" 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> </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 ? ( {running ? (
<div className="flex items-center gap-2 shrink-0"> <div className="flex items-center gap-2 shrink-0">
<Button {primaryAction}
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>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"
@ -789,15 +790,7 @@ export const Composer = memo(function Composer({
</Button> </Button>
</div> </div>
) : ( ) : (
<Button primaryAction
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>
)} )}
</div> </div>
</div> </div>

View File

@ -35,6 +35,12 @@ export function ComputerPanelPreview({
onOpen: () => void; onOpen: () => void;
}) { }) {
const { t } = useLingui(); const { t } = useLingui();
// Opening the panel never starts the computer, so a resting one shows its Open action outright.
const resting =
!booting &&
computer?.state !== "running" &&
computer?.state !== "booting" &&
!computersAreUnavailable(sandboxProvider);
return ( return (
<> <>
<div <div
@ -77,7 +83,8 @@ export function ComputerPanelPreview({
<button <button
type="button" type="button"
data-testid="computer-preview-open" data-testid="computer-preview-open"
className="absolute inset-0 flex cursor-pointer items-center justify-center bg-overlay/40 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100" data-resting={resting ? "" : undefined}
className="absolute inset-0 flex cursor-pointer items-center justify-center opacity-0 transition-opacity group-hover:bg-overlay/40 group-hover:opacity-100 focus-visible:bg-overlay/40 focus-visible:opacity-100 data-resting:items-end data-resting:pb-4 data-resting:opacity-100"
aria-label={t`Open`} aria-label={t`Open`}
onClick={onOpen} onClick={onOpen}
> >

View File

@ -286,10 +286,13 @@ git_fetch_and_checkout() {
log "previous HEAD: $previous_sha" log "previous HEAD: $previous_sha"
log "fetching $EVO_GIT_REMOTE…" log "fetching $EVO_GIT_REMOTE…"
run git -C "$EVO_DEPLOY_DIR" fetch "$EVO_GIT_REMOTE" --prune >&2 run git -C "$EVO_DEPLOY_DIR" fetch "$EVO_GIT_REMOTE" --prune >&2
if git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then # Prefer the freshly fetched remote branch: the local `main` in the deploy
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_DEPLOY_REF}^{commit}")" # checkout never moves (we reset HEAD, not the branch), so resolving it first
elif git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then # would redeploy a stale commit.
if git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}")" target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_GIT_REMOTE}/${EVO_DEPLOY_REF}^{commit}")"
elif git -C "$EVO_DEPLOY_DIR" rev-parse --verify "${EVO_DEPLOY_REF}^{commit}" >/dev/null 2>&1; then
target_sha="$(git -C "$EVO_DEPLOY_DIR" rev-parse "${EVO_DEPLOY_REF}^{commit}")"
else else
die "cannot resolve ref: $EVO_DEPLOY_REF" die "cannot resolve ref: $EVO_DEPLOY_REF"
fi fi