Side panel Routinen: list toggles, hide when empty (#180)

* feat(web): routine list toggles and hide empty Routinen section

Show Routinen in the Oo side panel only when the bot has routines; remove
the panel create control. Each list row gets an active switch with optimistic
updates via routines.update. E2E covers visibility, toggle persistence, and
updates existing routine specs to create via RPC like chat.

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): sync lingui catalogs after routine panel UI change

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): refresh routine list optimistically after save

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* test(e2e): wait for routine list after RPC create in golden path

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* style: format golden e2e after routine list wait

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

* fix(web): show sign-up while session lookup is pending

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
cynarAI 2026-10-07 23:41:11 +02:00 committed by GitHub
parent e4b995ec14
commit a2c7182ed6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 314 additions and 178 deletions

View File

@ -119,22 +119,30 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te
});
await captureScreenshot(page, testInfo, "09a-computer-takeover-skipped");
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Monday briefing");
await page
.locator("label:has-text('Instruction') textarea")
.fill("write a file in your home called notes/result.txt that says routine-ok");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "On a schedule" }).hover();
await page.getByRole("menuitem", { name: "Every day", exact: true }).click();
const savedRoutine = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
const botId = activeBotId(page);
await rpc(page, "routines/create", {
botId,
name: "Monday briefing",
prompt: "write a file in your home called notes/result.txt that says routine-ok",
crons: ["0 9 * * *"],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
});
if ((await sidePanel.getAttribute("data-panel")) === "computer") {
await page.getByTitle("Agent computer").click();
}
const routinesListed = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/list") && response.ok(),
);
await page.getByRole("button", { name: "Save" }).click();
await savedRoutine;
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
await page.getByRole("button", { name: "Back" }).click();
await expect(page.getByText("Monday briefing")).toBeVisible();
await page.getByTitle("Agent computer").click();
await routinesListed;
await expect(
sidePanel.getByTestId("routine-list-row").getByText("Monday briefing"),
).toBeVisible();
await captureScreenshot(page, testInfo, "10-routine-created");
await openUserSettings(page, "integrations");

View File

@ -1,4 +1,5 @@
import { expect, type Locator, type Page, type Route, type TestInfo } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
type RpcEnvelope = { json?: Record<string, unknown> };
@ -26,6 +27,39 @@ export function activeBotId(page: Page) {
return id;
}
export async function createTestRoutine(
page: Page,
botId: string,
overrides: {
name?: string;
prompt?: string;
crons?: string[];
timezone?: string;
active?: boolean;
webhookEnabled?: boolean;
githubEnabled?: boolean;
messageProvider?: string | null;
} = {},
) {
return rpc<Routine>(page, "routines/create", {
botId,
name: "Test routine",
prompt: "Run the check-in",
crons: ["0 9 * * *"],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
...overrides,
});
}
export async function openAgentComputerPanel(page: Page) {
await page.getByTitle("Agent computer").click();
}
export async function rpc<T>(page: Page, procedure: string, body: unknown): Promise<T> {
const spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const response = await page.request.post(`/rpc/${procedure}`, {

View File

@ -5,6 +5,8 @@ import {
captureScreenshot,
completeOnboarding,
createNamedBot,
createTestRoutine,
openAgentComputerPanel,
rpc,
signup,
} from "./helpers";
@ -29,8 +31,10 @@ test("routine active switch keeps its thumb inside the track", async ({ page },
const stamp = Date.now();
await signup(page, `routine-toggle-${stamp}@rakazo.test`, "password12", "Routine Toggle");
await completeOnboarding(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
const botId = activeBotId(page);
await createTestRoutine(page, botId, { name: "Toggle styling" });
await openAgentComputerPanel(page);
await page.getByRole("button", { name: /Toggle styling/ }).click();
const toggle = page.getByRole("switch", { name: "Active" });
const thumb = toggle.locator("span");
@ -133,23 +137,27 @@ test("routine editing updates in place, preserves timezone, and deletion persist
await expect(updatedButton).toHaveCount(0);
});
test("invalid advanced cron is rejected without creating a routine", async ({ page }, testInfo) => {
test("invalid advanced cron is rejected without creating a routine", async ({ page }) => {
const stamp = Date.now();
await signup(page, `routine-invalid-${stamp}@rakazo.test`, "password12", "Invalid Routine");
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Broken schedule");
await page.locator("label:has-text('Instruction') textarea").fill("This should never run");
await addScheduleTrigger(page, "Advanced...");
await page.getByLabel("Cron expression").fill("61 25 * * *");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByRole("alert")).toContainText("Enter a valid cron expression.");
await expect(
rpc<Routine>(page, "routines/create", {
botId,
name: "Broken schedule",
prompt: "This should never run",
crons: ["61 25 * * *"],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
}),
).rejects.toThrow();
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]);
await captureScreenshot(page, testInfo, "invalid-cron-rejected");
});
test("a successful routine create is not reported as failed when refresh fails", async ({
@ -160,26 +168,30 @@ test("a successful routine create is not reported as failed when refresh fails",
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Persisted routine");
await page.locator("label:has-text('Instruction') textarea").fill("Run once each morning");
await addScheduleTrigger(page, "Every day");
const created = await createTestRoutine(page, botId, {
name: "Persisted routine",
prompt: "Run once each morning",
});
await openAgentComputerPanel(page);
await page.getByRole("button", { name: /Persisted routine/ }).click();
await page
.locator("label:has-text('Instruction') textarea")
.fill("Run once each morning, revised");
await page.route(
"**/rpc/routines/list",
(route) => route.fulfill({ status: 500, body: "refresh failed" }),
{ times: 1 },
);
const createResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
const updateResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/update") && response.ok(),
);
const failedRefresh = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/list") && response.status() === 500,
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await createResponse;
await updateResponse;
await failedRefresh;
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "routine");
await expect(page.getByRole("alert")).toHaveCount(0);
@ -187,7 +199,8 @@ test("a successful routine create is not reported as failed when refresh fails",
await page.unroute("**/rpc/routines/list");
const routines = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routines).toHaveLength(1);
expect(routines[0]?.name).toBe("Persisted routine");
expect(routines[0]?.id).toBe(created.id);
expect(routines[0]?.prompt).toBe("Run once each morning, revised");
});
test("switching bots while a routine save is pending does not reopen stale state", async ({

View File

@ -1,6 +1,14 @@
import { expect, test } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
import { activeBotId, captureScreenshot, completeOnboarding, rpc, signup } from "./helpers";
import {
activeBotId,
captureScreenshot,
completeOnboarding,
createTestRoutine,
openAgentComputerPanel,
rpc,
signup,
} from "./helpers";
test("Slack message trigger uses the mounted messaging provider and persists", async ({
page,
@ -18,26 +26,21 @@ test("Slack message trigger uses the mounted messaging provider and persists", a
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.getByPlaceholder("Name this routine").fill("Triage Slack updates");
await page
.getByPlaceholder("What should this routine do each time it runs?")
.fill("Review the verified message event");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "Slack message", exact: true }).click();
await rpc<Routine>(page, "routines/create", {
botId,
name: "Triage Slack updates",
prompt: "Review the verified message event",
crons: [],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: "slack",
});
await openAgentComputerPanel(page);
const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Slack message", { exact: true })).toBeVisible();
await expect(
panel.getByText("Runs when this bot receives a verified message from this provider."),
).toBeVisible();
const saved = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await saved;
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine).toMatchObject({
name: "Triage Slack updates",
@ -57,26 +60,25 @@ test("GitHub event trigger exposes signed delivery settings and persists", async
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await page.getByRole("button", { name: "Create Routine" }).click();
await page.getByPlaceholder("Name this routine").fill("Review repository events");
await page
.getByPlaceholder("What should this routine do each time it runs?")
.fill("Inspect the signed GitHub event");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "Git event", exact: true }).click();
await rpc<Routine>(page, "routines/create", {
botId,
name: "Review repository events",
prompt: "Inspect the signed GitHub event",
crons: [],
timezone: "UTC",
active: true,
notify: true,
webhookEnabled: false,
githubEnabled: true,
messageProvider: null,
});
await openAgentComputerPanel(page);
await page.getByRole("button", { name: /Review repository events/ }).click();
await expect(
page.getByTestId("side-panel").getByText("Git event", { exact: true }),
).toBeVisible();
await expect(page.getByText(new RegExp(`/api/v1/bots/${botId}/github$`))).toBeVisible();
await expect(page.getByText("X-Hub-Signature-256: sha256=…", { exact: true })).toBeVisible();
const saved = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByRole("button", { name: "Save", exact: true }).click();
await saved;
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine).toMatchObject({
name: "Review repository events",
@ -102,14 +104,15 @@ test("Korean webhook routine keeps technical field labels in English", async ({
await settings.getByRole("option", { name: "한국어", exact: true }).click();
await page.getByRole("button", { name: "계정 설정 닫기" }).click();
const botId = activeBotId(page);
await createTestRoutine(page, botId, {
name: "한국어 웹훅 확인",
prompt: "웹훅을 확인합니다.",
crons: [],
webhookEnabled: true,
});
await page.getByTitle("Agent 컴퓨터").click();
await page.getByRole("button", { name: "자동 실행 만들기" }).click();
await page.getByPlaceholder("이 루틴의 이름을 정하세요").fill("한국어 웹훅 확인");
await page
.getByPlaceholder("이 루틴이 실행될 때마다 무엇을 해야 하나요?")
.fill("웹훅을 확인합니다.");
await page.getByRole("button", { name: "트리거 추가" }).click();
await page.getByRole("menuitem", { name: "웹훅", exact: true }).click();
await page.getByRole("button", { name: /한국어 웹훅 확인/ }).click();
await expect(page.getByText("웹훅이 실행될 때", { exact: true })).toBeVisible();
await expect(page.getByText("POST 대상")).toBeVisible();
@ -122,27 +125,17 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo
const stamp = Date.now();
await signup(page, `routine-${stamp}@rakazo.test`, "password12", "Routine");
await completeOnboarding(page);
const botId = activeBotId(page);
await page.getByTitle("Agent computer").click();
await openAgentComputerPanel(page);
await expect(page.getByRole("button", { name: "Test run" })).toHaveCount(0);
await page.getByRole("button", { name: "Create Routine" }).click();
await page.locator("label:has-text('Name') input").fill("Daily verification");
await page
.locator("label:has-text('Instruction') textarea")
.fill("write routine-run-now-ok into the durable task result");
await page.getByRole("button", { name: "Add trigger" }).click();
await page.getByRole("menuitem", { name: "On a schedule" }).hover();
await page.getByRole("menuitem", { name: "Weekdays", exact: true }).click();
await expect(page.getByLabel("How often")).toHaveValue("Weekdays");
await captureScreenshot(page, testInfo, "32-routine-configured");
const saved = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(),
);
await page.getByRole("button", { name: "Save" }).click();
await saved;
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled();
await page.getByRole("button", { name: "Back" }).click();
await createTestRoutine(page, botId, {
name: "Daily verification",
prompt: "write routine-run-now-ok into the durable task result",
crons: ["0 9 * * 1-5"],
});
await page.reload();
await openAgentComputerPanel(page);
const routine = page.getByRole("button", { name: /Daily verification/ });
await expect(routine).toContainText("Weekdays at 9:00 AM");
await captureScreenshot(page, testInfo, "33-routine-scheduled");

View File

@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test";
import type { Routine } from "@rakazo/contracts";
import {
activeBotId,
completeOnboarding,
createTestRoutine,
openAgentComputerPanel,
rpc,
signup,
} from "./helpers";
test("routines section is hidden until the Oo has one, then list toggle persists", async ({
page,
}) => {
const stamp = Date.now();
await signup(page, `routine-list-${stamp}@rakazo.test`, "password12", "Routine List");
await completeOnboarding(page);
const botId = activeBotId(page);
await openAgentComputerPanel(page);
const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Routines", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("routine-create-button")).toHaveCount(0);
await createTestRoutine(page, botId, { name: "Morning check-in" });
await page.reload();
await openAgentComputerPanel(page);
await expect(panel.getByText("Routines", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /Morning check-in/ })).toBeVisible();
const toggle = page.getByTestId("routine-list-active-toggle");
await expect(toggle).toHaveAttribute("aria-checked", "true");
await expect(page.getByText("Every day", { exact: false })).toBeVisible();
const updated = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/update") && response.ok(),
);
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", "false");
await updated;
const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine?.active).toBe(false);
await expect(page.getByText("Paused", { exact: true })).toBeVisible();
await toggle.click();
await expect(toggle).toHaveAttribute("aria-checked", "true");
const [reactivated] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(reactivated?.active).toBe(true);
});

View File

@ -2,13 +2,14 @@ import { Trans, useLingui } from "@lingui/react/macro";
import { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
import { Button, Skeleton } from "@rakazo/ui-web";
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Navigate, Route, Routes, useSearchParams } from "react-router-dom";
import { Navigate, Route, Routes, useLocation, useSearchParams } from "react-router-dom";
import { LoadingState } from "./components/ai/primitives";
import { authClient } from "./lib/auth";
import { authReturnPath } from "./lib/auth-next";
import { lazyWithRecovery } from "./lib/lazy-recovery";
import { markAfterPaint, markOnce } from "./lib/performance";
import {
allowsAnonymousEntryWhileSessionPending,
holdUnreachableGate,
sessionGate,
sessionRetryDelayMs,
@ -47,6 +48,7 @@ export function App() {
function SessionApp() {
const [searchParams] = useSearchParams();
const { pathname } = useLocation();
const signInDestination = authReturnPath(searchParams.get("next"));
const session = authClient.useSession();
const gate = sessionGate(session);
@ -63,8 +65,8 @@ function SessionApp() {
if (showSessionUnavailable(gate, nextHolding)) {
return <SessionUnavailable refetch={session.refetch} />;
}
if (gate === "loading") {
return window.location.pathname.startsWith("/app") ? (
if (gate === "loading" && !allowsAnonymousEntryWhileSessionPending(pathname)) {
return pathname.startsWith("/app") ? (
<ShellSkeleton />
) : (
<div

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
allowsAnonymousEntryWhileSessionPending,
holdUnreachableGate,
sessionGate,
sessionRetryDelayMs,
@ -54,4 +55,12 @@ describe("session gate", () => {
expect(sessionRetryDelayMs(3)).toBe(8_000);
expect(sessionRetryDelayMs(20)).toBe(15_000);
});
it("allows public entry routes while the session lookup is pending", () => {
expect(allowsAnonymousEntryWhileSessionPending("/sign-up")).toBe(true);
expect(allowsAnonymousEntryWhileSessionPending("/sign-in")).toBe(true);
expect(allowsAnonymousEntryWhileSessionPending("/")).toBe(true);
expect(allowsAnonymousEntryWhileSessionPending("/onboarding")).toBe(false);
expect(allowsAnonymousEntryWhileSessionPending("/app")).toBe(false);
});
});

View File

@ -37,6 +37,17 @@ export function showSessionUnavailable(gate: SessionGate, holding: boolean): boo
return gate === "unreachable" || (holding && gate === "loading");
}
/** Sign-in/up and marketing entry stay usable while the first session lookup is in flight. */
export function allowsAnonymousEntryWhileSessionPending(pathname: string): boolean {
return (
pathname === "/" ||
pathname === "/sign-in" ||
pathname === "/sign-up" ||
pathname === "/forgot-password" ||
pathname === "/reset-password"
);
}
const RETRY_BASE_MS = 1_000;
const RETRY_MAX_MS = 15_000;

View File

@ -1879,10 +1879,6 @@ msgstr "Neuen Space erstellen"
msgid "Create project"
msgstr "Projekt erstellen"
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr "Routine erstellen"
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "Create new Space"
msgid "Create project"
msgstr "Create project"
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr "Create Routine"
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "Crear nuevo space"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr "Crear rutina"
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "नया स्पेस बनाएं"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr ""
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "새 스페이스 만들기"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr "자동 실행 만들기"
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "Criar novo space"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr ""
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "Создать новое пространство"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr "Создать задачу"
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "Yeni space oluştur"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr ""
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -1879,10 +1879,6 @@ msgstr "新建工作区"
msgid "Create project"
msgstr ""
#: src/pages/RoutineEditor.tsx
msgid "Create Routine"
msgstr ""
#: src/components/AskCard.tsx
#: src/pages/shell/dialogs.tsx
msgid "Create space"

View File

@ -110,23 +110,12 @@ export function routineTriggerSummary(routine: Routine): string {
return parts.length > 0 ? parts.join(" · ") : t`No trigger`;
}
export function RoutineListHeader({ onCreate }: { onCreate: () => void }) {
const { t } = useLingui();
export function RoutineListHeader() {
return (
<div className="mt-[30px] mb-3 flex items-center justify-between gap-3">
<div className="mt-[30px] mb-3">
<div className="text-sm text-muted-foreground">
<Trans>Routines</Trans>
</div>
<Button
variant="secondary"
size="icon-sm"
data-testid="routine-create-button"
aria-label={t`Create Routine`}
title={t`Create Routine`}
onClick={onCreate}
>
<Plus strokeWidth={1.9} />
</Button>
</div>
);
}
@ -136,14 +125,23 @@ export function RoutineListRow({
running,
onOpen,
onStop,
onActiveChange,
togglingActive,
}: {
routine: Routine;
running: boolean;
onOpen: () => void;
onStop: () => void;
onActiveChange: (active: boolean) => void;
togglingActive?: boolean;
}) {
const { t } = useLingui();
return (
<div className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2.5 hover:bg-accent">
<div
className="flex w-full items-center gap-2 rounded-xl px-2.5 py-2.5 hover:bg-accent"
data-testid="routine-list-row"
data-routine-id={routine.id}
>
<button
type="button"
onClick={onOpen}
@ -174,6 +172,15 @@ export function RoutineListRow({
<Trans>Running · Stop</Trans>
</Button>
) : null}
<Switch
checked={routine.active}
disabled={togglingActive}
aria-label={t`Active`}
data-testid="routine-list-active-toggle"
onCheckedChange={onActiveChange}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
/>
</div>
);
}

View File

@ -397,6 +397,8 @@ export function ShellPage() {
} | null>(null);
const [routines, setRoutines] = useState<Routine[]>([]);
const [routinesBotId, setRoutinesBotId] = useState<string | null>(null);
const [routineActiveToggleId, setRoutineActiveToggleId] = useState<string | null>(null);
const routineActiveToggleRequest = useRef(0);
const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]);
const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null);
const [teachBusy, setTeachBusy] = useState(false);
@ -2407,6 +2409,43 @@ export function ShellPage() {
setEditingRoutine(null);
setPanel("routine");
}, []);
const setRoutineActive = useCallback(
async (routine: Routine, active: boolean) => {
const botId = activeBotId.current;
if (!botId || routine.botId !== botId) return;
const request = ++routineActiveToggleRequest.current;
const previousActive = routine.active;
setRoutineActiveToggleId(routine.id);
setRoutines((current) =>
routinesBotId === botId
? current.map((item) => (item.id === routine.id ? { ...item, active } : item))
: current,
);
try {
const saved = await rpc.routines.update({ routineId: routine.id, active });
if (routineActiveToggleRequest.current !== request || activeBotId.current !== botId) return;
setRoutines((current) =>
routinesBotId === botId
? current.map((item) => (item.id === saved.id ? saved : item))
: current,
);
} catch {
if (routineActiveToggleRequest.current !== request || activeBotId.current !== botId) return;
setRoutines((current) =>
routinesBotId === botId
? current.map((item) =>
item.id === routine.id ? { ...item, active: previousActive } : item,
)
: current,
);
} finally {
if (routineActiveToggleRequest.current === request) {
setRoutineActiveToggleId((current) => (current === routine.id ? null : current));
}
}
},
[routinesBotId],
);
const speakingMessageIdRef = useRef(speakingMessageId);
speakingMessageIdRef.current = speakingMessageId;
const speakMessage = useCallback((message: ThreadMessage) => {
@ -3541,34 +3580,32 @@ export function ShellPage() {
sandboxProvider={bootstrapMe?.sandboxProvider}
onOpen={() => void openComputer()}
/>
<RoutineListHeader
onCreate={() => {
setRoutineDraft(emptyRoutineDraft());
setRoutineWebhookSecret(null);
setEditingRoutine(null);
setRoutineError(null);
setPanel("routine");
}}
/>
{activeRoutines.map((routine) => {
const routineRunning =
snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status);
return (
<RoutineListRow
key={routine.id}
routine={routine}
running={routineRunning}
onOpen={() => {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setRoutineError(null);
setPanel("routine");
}}
onStop={() => void stopRun()}
/>
);
})}
{activeRoutines.length > 0 ? (
<>
<RoutineListHeader />
{activeRoutines.map((routine) => {
const routineRunning =
snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status);
return (
<RoutineListRow
key={routine.id}
routine={routine}
running={routineRunning}
togglingActive={routineActiveToggleId === routine.id}
onActiveChange={(next) => void setRoutineActive(routine, next)}
onOpen={() => {
setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null);
setEditingRoutine(routine);
setRoutineError(null);
setPanel("routine");
}}
onStop={() => void stopRun()}
/>
);
})}
</>
) : null}
</div>
) : null}
{panel === "create-group" ? (
@ -3746,6 +3783,14 @@ export function ShellPage() {
) {
setEditingRoutine(saved);
setRoutineDraft(draftFromRoutine(saved));
setRoutines((current) =>
routinesBotId === targetBotId || routinesBotId === null
? current.some((item) => item.id === saved.id)
? current.map((item) => (item.id === saved.id ? saved : item))
: [...current, saved]
: current,
);
setRoutinesBotId(targetBotId);
}
} catch (error) {
if (