diff --git a/apps/web/e2e/golden.spec.ts b/apps/web/e2e/golden.spec.ts index 447a582..48ac601 100644 --- a/apps/web/e2e/golden.spec.ts +++ b/apps/web/e2e/golden.spec.ts @@ -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"); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 8e2d7ea..ea65103 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -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 }; @@ -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(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(page: Page, procedure: string, body: unknown): Promise { const spaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id")); const response = await page.request.post(`/rpc/${procedure}`, { diff --git a/apps/web/e2e/routine-crud.spec.ts b/apps/web/e2e/routine-crud.spec.ts index 4362410..e4c138b 100644 --- a/apps/web/e2e/routine-crud.spec.ts +++ b/apps/web/e2e/routine-crud.spec.ts @@ -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(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(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(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 ({ diff --git a/apps/web/e2e/routine-execution.spec.ts b/apps/web/e2e/routine-execution.spec.ts index 478d054..fa3dcf5 100644 --- a/apps/web/e2e/routine-execution.spec.ts +++ b/apps/web/e2e/routine-execution.spec.ts @@ -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(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(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(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(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"); diff --git a/apps/web/e2e/routine-list-toggle.spec.ts b/apps/web/e2e/routine-list-toggle.spec.ts new file mode 100644 index 0000000..244071c --- /dev/null +++ b/apps/web/e2e/routine-list-toggle.spec.ts @@ -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(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(page, "routines/list", { botId }); + expect(reactivated?.active).toBe(true); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 3b1e012..9888b12 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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 ; } - if (gate === "loading") { - return window.location.pathname.startsWith("/app") ? ( + if (gate === "loading" && !allowsAnonymousEntryWhileSessionPending(pathname)) { + return pathname.startsWith("/app") ? ( ) : (
{ 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); + }); }); diff --git a/apps/web/src/lib/session-gate.ts b/apps/web/src/lib/session-gate.ts index 9a6ba78..1d0f446 100644 --- a/apps/web/src/lib/session-gate.ts +++ b/apps/web/src/lib/session-gate.ts @@ -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; diff --git a/apps/web/src/locales/de/messages.po b/apps/web/src/locales/de/messages.po index e5ea528..0db6e3d 100644 --- a/apps/web/src/locales/de/messages.po +++ b/apps/web/src/locales/de/messages.po @@ -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" diff --git a/apps/web/src/locales/en/messages.po b/apps/web/src/locales/en/messages.po index ebd4e55..ffe2027 100644 --- a/apps/web/src/locales/en/messages.po +++ b/apps/web/src/locales/en/messages.po @@ -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" diff --git a/apps/web/src/locales/es/messages.po b/apps/web/src/locales/es/messages.po index 8b196d4..4786a24 100644 --- a/apps/web/src/locales/es/messages.po +++ b/apps/web/src/locales/es/messages.po @@ -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" diff --git a/apps/web/src/locales/hi/messages.po b/apps/web/src/locales/hi/messages.po index 51bfffd..4faeaae 100644 --- a/apps/web/src/locales/hi/messages.po +++ b/apps/web/src/locales/hi/messages.po @@ -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" diff --git a/apps/web/src/locales/ko/messages.po b/apps/web/src/locales/ko/messages.po index a927ff7..b612da3 100644 --- a/apps/web/src/locales/ko/messages.po +++ b/apps/web/src/locales/ko/messages.po @@ -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" diff --git a/apps/web/src/locales/pt-BR/messages.po b/apps/web/src/locales/pt-BR/messages.po index 69732b9..f8cd472 100644 --- a/apps/web/src/locales/pt-BR/messages.po +++ b/apps/web/src/locales/pt-BR/messages.po @@ -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" diff --git a/apps/web/src/locales/ru/messages.po b/apps/web/src/locales/ru/messages.po index 2ffa8c0..4626dcc 100644 --- a/apps/web/src/locales/ru/messages.po +++ b/apps/web/src/locales/ru/messages.po @@ -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" diff --git a/apps/web/src/locales/tr/messages.po b/apps/web/src/locales/tr/messages.po index f6e9605..9913c6b 100644 --- a/apps/web/src/locales/tr/messages.po +++ b/apps/web/src/locales/tr/messages.po @@ -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" diff --git a/apps/web/src/locales/zh-CN/messages.po b/apps/web/src/locales/zh-CN/messages.po index a66a4d7..9a2806b 100644 --- a/apps/web/src/locales/zh-CN/messages.po +++ b/apps/web/src/locales/zh-CN/messages.po @@ -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" diff --git a/apps/web/src/pages/RoutineEditor.tsx b/apps/web/src/pages/RoutineEditor.tsx index 29cc9d9..2a3a202 100644 --- a/apps/web/src/pages/RoutineEditor.tsx +++ b/apps/web/src/pages/RoutineEditor.tsx @@ -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 ( -
+
Routines
-
); } @@ -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 ( -
+
) : null} + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + />
); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 7ce6742..691bfc7 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -397,6 +397,8 @@ export function ShellPage() { } | null>(null); const [routines, setRoutines] = useState([]); const [routinesBotId, setRoutinesBotId] = useState(null); + const [routineActiveToggleId, setRoutineActiveToggleId] = useState(null); + const routineActiveToggleRequest = useRef(0); const [taughtSkills, setTaughtSkills] = useState([]); const [taughtSkillsBotId, setTaughtSkillsBotId] = useState(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()} /> - { - 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 ( - { - setRoutineDraft(draftFromRoutine(routine)); - setRoutineWebhookSecret(null); - setEditingRoutine(routine); - setRoutineError(null); - setPanel("routine"); - }} - onStop={() => void stopRun()} - /> - ); - })} + {activeRoutines.length > 0 ? ( + <> + + {activeRoutines.map((routine) => { + const routineRunning = + snapshot?.run?.routineId === routine.id && isActive(snapshot.run.status); + return ( + void setRoutineActive(routine, next)} + onOpen={() => { + setRoutineDraft(draftFromRoutine(routine)); + setRoutineWebhookSecret(null); + setEditingRoutine(routine); + setRoutineError(null); + setPanel("routine"); + }} + onStop={() => void stopRun()} + /> + ); + })} + + ) : null}
) : 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 (