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

@ -1,5 +1,5 @@
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) => {
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.getByTestId("computer-more-button")).toHaveCount(0);
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");
const preview = sidePanel.getByTestId("computer-preview");
await preview.hover();
const openButton = sidePanel.getByTestId("computer-preview-open");
await expect(openButton).toBeVisible();
await expect(openButton.getByText("Open", { exact: true })).toBeVisible();
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/);
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

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

View File

@ -539,27 +539,23 @@ export function computerTakeoverBlocked(
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,
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 {
return action === "boot" || action === "recover-screen";
return state === "running" && !screenUrl;
}
export function computerPanelNeedsMaintenance(
state: ComputerStatus["state"] | undefined,
booting: boolean,
): boolean {
return !booting && (state === "error" || state === "stopped");
return !booting && state === "error";
}
export function reduceComputerStatus(

View File

@ -4579,7 +4579,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"
@ -4834,10 +4833,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."
@ -5378,10 +5373,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

@ -4579,7 +4579,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"
@ -4834,10 +4833,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."
@ -5378,10 +5373,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

@ -4579,7 +4579,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 ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

@ -4579,7 +4579,6 @@ msgstr "सर्वर का नाम"
msgid "Server URL"
msgstr "सर्वर URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

@ -4579,7 +4579,6 @@ msgstr "서버 이름"
msgid "Server URL"
msgstr "서버 URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

@ -4579,7 +4579,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 ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

@ -4579,7 +4579,6 @@ msgstr "Имя сервера"
msgid "Server URL"
msgstr "URL-адрес сервера"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

@ -4579,7 +4579,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 ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

@ -4579,7 +4579,6 @@ msgstr "服务器名称"
msgid "Server URL"
msgstr "服务器 URL"
#: src/components/stage/StageVoiceEntry.tsx
#: src/pages/shell/composer.tsx
msgid "Set up voice"
msgstr ""
@ -4834,10 +4833,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 ""
@ -5378,10 +5373,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

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

View File

@ -35,6 +35,12 @@ export function ComputerPanelPreview({
onOpen: () => void;
}) {
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 (
<>
<div
@ -77,7 +83,8 @@ export function ComputerPanelPreview({
<button
type="button"
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`}
onClick={onOpen}
>

View File

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