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:
parent
e4b995ec14
commit
a2c7182ed6
@ -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");
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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 ({
|
||||
|
||||
@ -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");
|
||||
|
||||
50
apps/web/e2e/routine-list-toggle.spec.ts
Normal file
50
apps/web/e2e/routine-list-toggle.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user