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 captureScreenshot(page, testInfo, "09a-computer-takeover-skipped");
await page.getByRole("button", { name: "Create Routine" }).click(); const botId = activeBotId(page);
await page.locator("label:has-text('Name') input").fill("Monday briefing"); await rpc(page, "routines/create", {
await page botId,
.locator("label:has-text('Instruction') textarea") name: "Monday briefing",
.fill("write a file in your home called notes/result.txt that says routine-ok"); prompt: "write a file in your home called notes/result.txt that says routine-ok",
await page.getByRole("button", { name: "Add trigger" }).click(); crons: ["0 9 * * *"],
await page.getByRole("menuitem", { name: "On a schedule" }).hover(); timezone: "UTC",
await page.getByRole("menuitem", { name: "Every day", exact: true }).click(); active: true,
const savedRoutine = page.waitForResponse( notify: true,
(response) => response.url().includes("/rpc/routines/create") && response.ok(), 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 page.getByTitle("Agent computer").click();
await savedRoutine; await routinesListed;
await expect(page.getByRole("button", { name: "Save" })).toBeEnabled(); await expect(
await page.getByRole("button", { name: "Back" }).click(); sidePanel.getByTestId("routine-list-row").getByText("Monday briefing"),
await expect(page.getByText("Monday briefing")).toBeVisible(); ).toBeVisible();
await captureScreenshot(page, testInfo, "10-routine-created"); await captureScreenshot(page, testInfo, "10-routine-created");
await openUserSettings(page, "integrations"); await openUserSettings(page, "integrations");

View File

@ -1,4 +1,5 @@
import { expect, type Locator, type Page, type Route, type TestInfo } from "@playwright/test"; 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> }; type RpcEnvelope = { json?: Record<string, unknown> };
@ -26,6 +27,39 @@ export function activeBotId(page: Page) {
return id; 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> { 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 spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const response = await page.request.post(`/rpc/${procedure}`, { const response = await page.request.post(`/rpc/${procedure}`, {

View File

@ -5,6 +5,8 @@ import {
captureScreenshot, captureScreenshot,
completeOnboarding, completeOnboarding,
createNamedBot, createNamedBot,
createTestRoutine,
openAgentComputerPanel,
rpc, rpc,
signup, signup,
} from "./helpers"; } from "./helpers";
@ -29,8 +31,10 @@ test("routine active switch keeps its thumb inside the track", async ({ page },
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `routine-toggle-${stamp}@rakazo.test`, "password12", "Routine Toggle"); await signup(page, `routine-toggle-${stamp}@rakazo.test`, "password12", "Routine Toggle");
await completeOnboarding(page); await completeOnboarding(page);
await page.getByTitle("Agent computer").click(); const botId = activeBotId(page);
await page.getByRole("button", { name: "Create Routine" }).click(); 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 toggle = page.getByRole("switch", { name: "Active" });
const thumb = toggle.locator("span"); 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); 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(); const stamp = Date.now();
await signup(page, `routine-invalid-${stamp}@rakazo.test`, "password12", "Invalid Routine"); await signup(page, `routine-invalid-${stamp}@rakazo.test`, "password12", "Invalid Routine");
await completeOnboarding(page); await completeOnboarding(page);
const botId = activeBotId(page); const botId = activeBotId(page);
await page.getByTitle("Agent computer").click(); await expect(
await page.getByRole("button", { name: "Create Routine" }).click(); rpc<Routine>(page, "routines/create", {
await page.locator("label:has-text('Name') input").fill("Broken schedule"); botId,
await page.locator("label:has-text('Instruction') textarea").fill("This should never run"); name: "Broken schedule",
await addScheduleTrigger(page, "Advanced..."); prompt: "This should never run",
await page.getByLabel("Cron expression").fill("61 25 * * *"); crons: ["61 25 * * *"],
await page.getByRole("button", { name: "Save", exact: true }).click(); timezone: "UTC",
active: true,
await expect(page.getByRole("alert")).toContainText("Enter a valid cron expression."); notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: null,
}),
).rejects.toThrow();
expect(await rpc<Routine[]>(page, "routines/list", { botId })).toEqual([]); 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 ({ 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); await completeOnboarding(page);
const botId = activeBotId(page); const botId = activeBotId(page);
await page.getByTitle("Agent computer").click(); const created = await createTestRoutine(page, botId, {
await page.getByRole("button", { name: "Create Routine" }).click(); name: "Persisted routine",
await page.locator("label:has-text('Name') input").fill("Persisted routine"); prompt: "Run once each morning",
await page.locator("label:has-text('Instruction') textarea").fill("Run once each morning"); });
await addScheduleTrigger(page, "Every day"); 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( await page.route(
"**/rpc/routines/list", "**/rpc/routines/list",
(route) => route.fulfill({ status: 500, body: "refresh failed" }), (route) => route.fulfill({ status: 500, body: "refresh failed" }),
{ times: 1 }, { times: 1 },
); );
const createResponse = page.waitForResponse( const updateResponse = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/create") && response.ok(), (response) => response.url().includes("/rpc/routines/update") && response.ok(),
); );
const failedRefresh = page.waitForResponse( const failedRefresh = page.waitForResponse(
(response) => response.url().includes("/rpc/routines/list") && response.status() === 500, (response) => response.url().includes("/rpc/routines/list") && response.status() === 500,
); );
await page.getByRole("button", { name: "Save", exact: true }).click(); await page.getByRole("button", { name: "Save", exact: true }).click();
await createResponse; await updateResponse;
await failedRefresh; await failedRefresh;
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "routine"); await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "routine");
await expect(page.getByRole("alert")).toHaveCount(0); 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"); await page.unroute("**/rpc/routines/list");
const routines = await rpc<Routine[]>(page, "routines/list", { botId }); const routines = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routines).toHaveLength(1); 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 ({ 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 { expect, test } from "@playwright/test";
import type { Routine } from "@rakazo/contracts"; 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 ({ test("Slack message trigger uses the mounted messaging provider and persists", async ({
page, page,
@ -18,26 +26,21 @@ test("Slack message trigger uses the mounted messaging provider and persists", a
await completeOnboarding(page); await completeOnboarding(page);
const botId = activeBotId(page); const botId = activeBotId(page);
await page.getByTitle("Agent computer").click(); await rpc<Routine>(page, "routines/create", {
await page.getByRole("button", { name: "Create Routine" }).click(); botId,
await page.getByPlaceholder("Name this routine").fill("Triage Slack updates"); name: "Triage Slack updates",
await page prompt: "Review the verified message event",
.getByPlaceholder("What should this routine do each time it runs?") crons: [],
.fill("Review the verified message event"); timezone: "UTC",
await page.getByRole("button", { name: "Add trigger" }).click(); active: true,
await page.getByRole("menuitem", { name: "Slack message", exact: true }).click(); notify: true,
webhookEnabled: false,
githubEnabled: false,
messageProvider: "slack",
});
await openAgentComputerPanel(page);
const panel = page.getByTestId("side-panel"); const panel = page.getByTestId("side-panel");
await expect(panel.getByText("Slack message", { exact: true })).toBeVisible(); 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 }); const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine).toMatchObject({ expect(routine).toMatchObject({
name: "Triage Slack updates", name: "Triage Slack updates",
@ -57,26 +60,25 @@ test("GitHub event trigger exposes signed delivery settings and persists", async
await completeOnboarding(page); await completeOnboarding(page);
const botId = activeBotId(page); const botId = activeBotId(page);
await page.getByTitle("Agent computer").click(); await rpc<Routine>(page, "routines/create", {
await page.getByRole("button", { name: "Create Routine" }).click(); botId,
await page.getByPlaceholder("Name this routine").fill("Review repository events"); name: "Review repository events",
await page prompt: "Inspect the signed GitHub event",
.getByPlaceholder("What should this routine do each time it runs?") crons: [],
.fill("Inspect the signed GitHub event"); timezone: "UTC",
await page.getByRole("button", { name: "Add trigger" }).click(); active: true,
await page.getByRole("menuitem", { name: "Git event", exact: true }).click(); notify: true,
webhookEnabled: false,
githubEnabled: true,
messageProvider: null,
});
await openAgentComputerPanel(page);
await page.getByRole("button", { name: /Review repository events/ }).click();
await expect( await expect(
page.getByTestId("side-panel").getByText("Git event", { exact: true }), page.getByTestId("side-panel").getByText("Git event", { exact: true }),
).toBeVisible(); ).toBeVisible();
await expect(page.getByText(new RegExp(`/api/v1/bots/${botId}/github$`))).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(); 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 }); const [routine] = await rpc<Routine[]>(page, "routines/list", { botId });
expect(routine).toMatchObject({ expect(routine).toMatchObject({
name: "Review repository events", 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 settings.getByRole("option", { name: "한국어", exact: true }).click();
await page.getByRole("button", { name: "계정 설정 닫기" }).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.getByTitle("Agent 컴퓨터").click();
await page.getByRole("button", { name: "자동 실행 만들기" }).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 expect(page.getByText("웹훅이 실행될 때", { exact: true })).toBeVisible(); await expect(page.getByText("웹훅이 실행될 때", { exact: true })).toBeVisible();
await expect(page.getByText("POST 대상")).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(); const stamp = Date.now();
await signup(page, `routine-${stamp}@rakazo.test`, "password12", "Routine"); await signup(page, `routine-${stamp}@rakazo.test`, "password12", "Routine");
await completeOnboarding(page); 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 expect(page.getByRole("button", { name: "Test run" })).toHaveCount(0);
await page.getByRole("button", { name: "Create Routine" }).click(); await createTestRoutine(page, botId, {
await page.locator("label:has-text('Name') input").fill("Daily verification"); name: "Daily verification",
await page prompt: "write routine-run-now-ok into the durable task result",
.locator("label:has-text('Instruction') textarea") crons: ["0 9 * * 1-5"],
.fill("write routine-run-now-ok into the durable task result"); });
await page.getByRole("button", { name: "Add trigger" }).click(); await page.reload();
await page.getByRole("menuitem", { name: "On a schedule" }).hover(); await openAgentComputerPanel(page);
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();
const routine = page.getByRole("button", { name: /Daily verification/ }); const routine = page.getByRole("button", { name: /Daily verification/ });
await expect(routine).toContainText("Weekdays at 9:00 AM"); await expect(routine).toContainText("Weekdays at 9:00 AM");
await captureScreenshot(page, testInfo, "33-routine-scheduled"); 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 { LOCAL_SETTINGS_PAGE } from "@rakazo/contracts";
import { Button, Skeleton } from "@rakazo/ui-web"; import { Button, Skeleton } from "@rakazo/ui-web";
import { Suspense, useEffect, useLayoutEffect, useRef, useState } from "react"; 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 { LoadingState } from "./components/ai/primitives";
import { authClient } from "./lib/auth"; import { authClient } from "./lib/auth";
import { authReturnPath } from "./lib/auth-next"; import { authReturnPath } from "./lib/auth-next";
import { lazyWithRecovery } from "./lib/lazy-recovery"; import { lazyWithRecovery } from "./lib/lazy-recovery";
import { markAfterPaint, markOnce } from "./lib/performance"; import { markAfterPaint, markOnce } from "./lib/performance";
import { import {
allowsAnonymousEntryWhileSessionPending,
holdUnreachableGate, holdUnreachableGate,
sessionGate, sessionGate,
sessionRetryDelayMs, sessionRetryDelayMs,
@ -47,6 +48,7 @@ export function App() {
function SessionApp() { function SessionApp() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { pathname } = useLocation();
const signInDestination = authReturnPath(searchParams.get("next")); const signInDestination = authReturnPath(searchParams.get("next"));
const session = authClient.useSession(); const session = authClient.useSession();
const gate = sessionGate(session); const gate = sessionGate(session);
@ -63,8 +65,8 @@ function SessionApp() {
if (showSessionUnavailable(gate, nextHolding)) { if (showSessionUnavailable(gate, nextHolding)) {
return <SessionUnavailable refetch={session.refetch} />; return <SessionUnavailable refetch={session.refetch} />;
} }
if (gate === "loading") { if (gate === "loading" && !allowsAnonymousEntryWhileSessionPending(pathname)) {
return window.location.pathname.startsWith("/app") ? ( return pathname.startsWith("/app") ? (
<ShellSkeleton /> <ShellSkeleton />
) : ( ) : (
<div <div

View File

@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
allowsAnonymousEntryWhileSessionPending,
holdUnreachableGate, holdUnreachableGate,
sessionGate, sessionGate,
sessionRetryDelayMs, sessionRetryDelayMs,
@ -54,4 +55,12 @@ describe("session gate", () => {
expect(sessionRetryDelayMs(3)).toBe(8_000); expect(sessionRetryDelayMs(3)).toBe(8_000);
expect(sessionRetryDelayMs(20)).toBe(15_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"); 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_BASE_MS = 1_000;
const RETRY_MAX_MS = 15_000; const RETRY_MAX_MS = 15_000;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -110,23 +110,12 @@ export function routineTriggerSummary(routine: Routine): string {
return parts.length > 0 ? parts.join(" · ") : t`No trigger`; return parts.length > 0 ? parts.join(" · ") : t`No trigger`;
} }
export function RoutineListHeader({ onCreate }: { onCreate: () => void }) { export function RoutineListHeader() {
const { t } = useLingui();
return ( 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"> <div className="text-sm text-muted-foreground">
<Trans>Routines</Trans> <Trans>Routines</Trans>
</div> </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> </div>
); );
} }
@ -136,14 +125,23 @@ export function RoutineListRow({
running, running,
onOpen, onOpen,
onStop, onStop,
onActiveChange,
togglingActive,
}: { }: {
routine: Routine; routine: Routine;
running: boolean; running: boolean;
onOpen: () => void; onOpen: () => void;
onStop: () => void; onStop: () => void;
onActiveChange: (active: boolean) => void;
togglingActive?: boolean;
}) { }) {
const { t } = useLingui();
return ( 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 <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
@ -174,6 +172,15 @@ export function RoutineListRow({
<Trans>Running · Stop</Trans> <Trans>Running · Stop</Trans>
</Button> </Button>
) : null} ) : 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> </div>
); );
} }

View File

@ -397,6 +397,8 @@ export function ShellPage() {
} | null>(null); } | null>(null);
const [routines, setRoutines] = useState<Routine[]>([]); const [routines, setRoutines] = useState<Routine[]>([]);
const [routinesBotId, setRoutinesBotId] = useState<string | null>(null); const [routinesBotId, setRoutinesBotId] = useState<string | null>(null);
const [routineActiveToggleId, setRoutineActiveToggleId] = useState<string | null>(null);
const routineActiveToggleRequest = useRef(0);
const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]); const [taughtSkills, setTaughtSkills] = useState<TaughtSkill[]>([]);
const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null); const [taughtSkillsBotId, setTaughtSkillsBotId] = useState<string | null>(null);
const [teachBusy, setTeachBusy] = useState(false); const [teachBusy, setTeachBusy] = useState(false);
@ -2407,6 +2409,43 @@ export function ShellPage() {
setEditingRoutine(null); setEditingRoutine(null);
setPanel("routine"); 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); const speakingMessageIdRef = useRef(speakingMessageId);
speakingMessageIdRef.current = speakingMessageId; speakingMessageIdRef.current = speakingMessageId;
const speakMessage = useCallback((message: ThreadMessage) => { const speakMessage = useCallback((message: ThreadMessage) => {
@ -3541,15 +3580,9 @@ export function ShellPage() {
sandboxProvider={bootstrapMe?.sandboxProvider} sandboxProvider={bootstrapMe?.sandboxProvider}
onOpen={() => void openComputer()} onOpen={() => void openComputer()}
/> />
<RoutineListHeader {activeRoutines.length > 0 ? (
onCreate={() => { <>
setRoutineDraft(emptyRoutineDraft()); <RoutineListHeader />
setRoutineWebhookSecret(null);
setEditingRoutine(null);
setRoutineError(null);
setPanel("routine");
}}
/>
{activeRoutines.map((routine) => { {activeRoutines.map((routine) => {
const routineRunning = const routineRunning =
snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status); snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status);
@ -3558,6 +3591,8 @@ export function ShellPage() {
key={routine.id} key={routine.id}
routine={routine} routine={routine}
running={routineRunning} running={routineRunning}
togglingActive={routineActiveToggleId === routine.id}
onActiveChange={(next) => void setRoutineActive(routine, next)}
onOpen={() => { onOpen={() => {
setRoutineDraft(draftFromRoutine(routine)); setRoutineDraft(draftFromRoutine(routine));
setRoutineWebhookSecret(null); setRoutineWebhookSecret(null);
@ -3569,6 +3604,8 @@ export function ShellPage() {
/> />
); );
})} })}
</>
) : null}
</div> </div>
) : null} ) : null}
{panel === "create-group" ? ( {panel === "create-group" ? (
@ -3746,6 +3783,14 @@ export function ShellPage() {
) { ) {
setEditingRoutine(saved); setEditingRoutine(saved);
setRoutineDraft(draftFromRoutine(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) { } catch (error) {
if ( if (