From 8eef9b54f3bec8bafca029df625c86b2e1ee9179 Mon Sep 17 00:00:00 2001 From: Yevhenii Petrenko Date: Tue, 27 Oct 2026 14:29:51 -0700 Subject: [PATCH] feat(web): open Scheduled tasks from the sidebar Scheduled tasks are something people check daily, but the list only lives in Settings and each thread's Automations panel. The same list and editor now also open as a modal page at /scheduled-tasks, built like Usage, from a sidebar footer button, the command palette, and an unbound "Scheduled Tasks: Open" keybinding. The editor closes on whichever page opened it, and thread automation links open the new page. Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/AppRouteModal.tsx | 6 +-- apps/web/src/components/CommandPalette.tsx | 13 +++++ .../chat/ThreadAutomationsPanel.tsx | 4 +- .../chat/TimelineUserMessageRow.tsx | 2 +- .../ScheduledTasksSettings.browser.test.tsx | 21 ++++++++ .../settings/ScheduledTasksSettings.tsx | 8 +-- .../settings/SettingsScopeSentence.tsx | 4 +- .../src/components/sidebar/SidebarChrome.tsx | 17 +++++- .../src/components/sidebar/mainAppLocation.ts | 13 +++-- apps/web/src/routeTree.gen.ts | 21 ++++++++ apps/web/src/routes/_chat.scheduled-tasks.tsx | 53 +++++++++++++++++++ .../src/shortcuts/commandPaletteCommands.ts | 5 ++ docs/user/project-settings.md | 8 ++- packages/contracts/src/keybindings.ts | 1 + 14 files changed, 159 insertions(+), 17 deletions(-) create mode 100644 apps/web/src/routes/_chat.scheduled-tasks.tsx diff --git a/apps/web/src/components/AppRouteModal.tsx b/apps/web/src/components/AppRouteModal.tsx index 89e676da2e3c3..7b52b79513a77 100644 --- a/apps/web/src/components/AppRouteModal.tsx +++ b/apps/web/src/components/AppRouteModal.tsx @@ -8,9 +8,9 @@ import { DialogBackdrop } from "./ui/dialog"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; /** - * Large modal for the Settings and Usage routes. Esc, the close button, or a press on the - * backdrop returns to the page that opened it. Keyboard guards that skip dialogs exempt - * `[data-app-modal]`, because these pages own their shortcuts. + * Large modal for the Settings, Usage, and Scheduled tasks routes. Esc, the close button, or a + * press on the backdrop returns to the page that opened it. Keyboard guards that skip dialogs + * exempt `[data-app-modal]`, because these pages own their shortcuts. */ export function AppRouteModal({ label, children }: { label: string; children: ReactNode }) { const closeAppModal = useCloseAppModal(); diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index c55f892423308..a29e2825628cc 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -53,6 +53,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router"; import { ArrowLeftIcon, BugIcon, + CalendarClockIcon, ChartNoAxesColumnIcon, CheckIcon, ChevronRightIcon, @@ -1524,6 +1525,18 @@ function OpenCommandPaletteDialog(props: { }, }); + actionItems.push({ + kind: "action", + value: "action:scheduled-tasks", + searchTerms: ["scheduled", "tasks", "schedule", "automations", "recurring", "cron", "webhook"], + title: "Open scheduled tasks", + icon: , + shortcutCommand: "scheduledTasks.open", + run: async () => { + await navigate({ to: "/scheduled-tasks" }); + }, + }); + actionItems.push({ kind: "action", value: "action:settings", diff --git a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx index 20ecde3272276..fe76c90e0eca3 100644 --- a/apps/web/src/components/chat/ThreadAutomationsPanel.tsx +++ b/apps/web/src/components/chat/ThreadAutomationsPanel.tsx @@ -140,7 +140,7 @@ export function ThreadAutomationsPanel(props: { aria-label="Manage scheduled tasks" onClick={() => void navigate({ - to: "/settings/scheduled-tasks", + to: "/scheduled-tasks", search: { environmentId: props.environmentId }, }) } @@ -200,7 +200,7 @@ export function ThreadAutomationsPanel(props: { aria-label={`Edit ${task.title}`} onClick={() => void navigate({ - to: "/settings/scheduled-tasks", + to: "/scheduled-tasks", search: { environmentId: props.environmentId, taskId: task.id }, }) } diff --git a/apps/web/src/components/chat/TimelineUserMessageRow.tsx b/apps/web/src/components/chat/TimelineUserMessageRow.tsx index 0c459717e48cd..6b5b8cc954e5c 100644 --- a/apps/web/src/components/chat/TimelineUserMessageRow.tsx +++ b/apps/web/src/components/chat/TimelineUserMessageRow.tsx @@ -198,7 +198,7 @@ function AutomationMessageRow({ row }: { row: Extract {scheduledTaskId ? ( diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx index e42242f01093e..8c32f0980a4f5 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.browser.test.tsx @@ -138,4 +138,25 @@ describe("ScheduledTasksSettings", () => { await expect.element(dialog.getByRole("heading", { name: "New task" })).toBeInTheDocument(); await expect.element(name).toHaveValue("My draft"); }); + + it("closes a linked task's editor on the Scheduled tasks page without leaving it", async () => { + state.connected = true; + state.tasksLoaded = true; + await using screen = await render( + , + { router: `/scheduled-tasks?environmentId=${environmentId}&taskId=${task.id}` }, + ); + const dialog = page.getByRole("dialog"); + await expect + .element(dialog.getByRole("textbox", { name: "Name" })) + .toHaveValue("Review issues"); + + await userEvent.click(dialog.getByRole("button", { name: "Cancel" })); + + await expect.element(dialog).not.toBeInTheDocument(); + await vi.waitFor(() => { + expect(screen.router!.state.location.pathname).toBe("/scheduled-tasks"); + expect(screen.router!.state.location.search).toEqual({ environmentId }); + }); + }); }); diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index d440a4fde05f6..53def80f59d90 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -56,7 +56,7 @@ import { import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; import { requestConfirmDialog } from "../../confirmDialog"; import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; -import { Link, useNavigate } from "@tanstack/react-router"; +import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useNowMinute } from "../../hooks/useNowMinute"; import { @@ -391,12 +391,14 @@ export function ScheduledTasksSettings(target: { setOpenedLinkTaskId(target.taskId); if (editor === null) setEditor({ environmentId: linkedEnvironmentId, task: linkedTask }); } - const navigate = useNavigate({ from: "/settings/scheduled-tasks" }); + // Settings and the Scheduled tasks page both host this list; closing stays on whichever is open. + const pathname = useLocation({ select: (location) => location.pathname }); + const navigate = useNavigate(); const closeEditor = () => { setEditor(null); if (target.taskId === undefined) return; void navigate({ - to: "/settings/scheduled-tasks", + to: pathname, search: ({ taskId: _closed, ...rest }) => rest, replace: true, }); diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index dfefa375bb974..1a09edb64bee8 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -77,7 +77,9 @@ export function SettingsScopeSentence() { return (

- Applying settings for + + {pathname === "/scheduled-tasks" ? "Showing tasks for" : "Applying settings for"} + diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx index ec161e9998548..b7725e75db5bf 100644 --- a/apps/web/src/components/sidebar/SidebarChrome.tsx +++ b/apps/web/src/components/sidebar/SidebarChrome.tsx @@ -1,4 +1,9 @@ -import { ArrowLeftIcon, ChartNoAxesColumnIcon, SettingsIcon } from "lucide-react"; +import { + ArrowLeftIcon, + CalendarClockIcon, + ChartNoAxesColumnIcon, + SettingsIcon, +} from "lucide-react"; import type { ReactNode } from "react"; import { memo } from "react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; @@ -188,6 +193,11 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { void navigate({ to: "/usage" }); }; + const handleScheduledTasksClick = () => { + closeMobileSidebar(); + void navigate({ to: "/scheduled-tasks" }); + }; + const handleBackClick = () => { closeMobileSidebar(); void navigateToMainApp(); @@ -221,6 +231,11 @@ const SidebarUtilityMenu = memo(function SidebarUtilityMenu() { label="Usage" onClick={handleUsageClick} /> + } + label="Scheduled tasks" + onClick={handleScheduledTasksClick} + /> )} diff --git a/apps/web/src/components/sidebar/mainAppLocation.ts b/apps/web/src/components/sidebar/mainAppLocation.ts index a732d9f435bfc..2ce4840eed176 100644 --- a/apps/web/src/components/sidebar/mainAppLocation.ts +++ b/apps/web/src/components/sidebar/mainAppLocation.ts @@ -2,9 +2,14 @@ import { useNavigate, type AnyRouter } from "@tanstack/react-router"; import { GUEST_PAGE_PATH } from "../../guestPage"; -/** Settings and Usage open as modals over the last main-app view. */ +/** Settings, Usage, and Scheduled tasks open as modals over the last main-app view. */ export function isAppModalPage(pathname: string) { - return pathname === "/settings" || pathname.startsWith("/settings/") || pathname === "/usage"; + return ( + pathname === "/settings" || + pathname.startsWith("/settings/") || + pathname === "/usage" || + pathname === "/scheduled-tasks" + ); } export function isSidebarUtilityPage(pathname: string) { @@ -19,7 +24,7 @@ const SHELL_LESS_PATHS = new Set(["/pair", "/connect", "/connect-agent", GUEST_P let mainAppHref: string | null = null; let appModalReturnHref: string | null = null; -/** Remembers where Settings, Usage, and the utility pages lead back to. */ +/** Remembers where the app modal pages and the utility pages lead back to. */ export function startMainAppLocationTracking(router: AnyRouter): () => void { const record = () => { const { pathname, href } = router.state.location; @@ -36,7 +41,7 @@ export function useNavigateToMainApp() { return () => navigate({ href: mainAppHref ?? "/" }); } -/** Leaves Settings or Usage for the page that opened it. */ +/** Leaves an app modal page for the page that opened it. */ export function useCloseAppModal() { const navigate = useNavigate(); return () => navigate({ href: appModalReturnHref ?? mainAppHref ?? "/" }); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index d92ea6110863e..788ec80df5eee 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as ProjectsProjectKeyRouteImport } from './routes/projects.$projectKey' import { Route as ChatUsageRouteImport } from './routes/_chat.usage' import { Route as ChatSettingsRouteImport } from './routes/_chat.settings' +import { Route as ChatScheduledTasksRouteImport } from './routes/_chat.scheduled-tasks' import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-requests' import { Route as ChatNewRouteImport } from './routes/_chat.new' import { Route as ChatSettingsWorktreesRouteImport } from './routes/_chat.settings.worktrees' @@ -98,6 +99,11 @@ const ChatSettingsRoute = ChatSettingsRouteImport.update({ path: '/settings', getParentRoute: () => ChatRoute, } as any) +const ChatScheduledTasksRoute = ChatScheduledTasksRouteImport.update({ + id: '/scheduled-tasks', + path: '/scheduled-tasks', + getParentRoute: () => ChatRoute, +} as any) const ChatPullRequestsRoute = ChatPullRequestsRouteImport.update({ id: '/pull-requests', path: '/pull-requests', @@ -258,6 +264,7 @@ export interface FileRoutesByFullPath { '/welcome': typeof WelcomeRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute + '/scheduled-tasks': typeof ChatScheduledTasksRoute '/settings': typeof ChatSettingsRouteWithChildren '/usage': typeof ChatUsageRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -297,6 +304,7 @@ export interface FileRoutesByTo { '/welcome': typeof WelcomeRoute '/new': typeof ChatNewRoute '/pull-requests': typeof ChatPullRequestsRoute + '/scheduled-tasks': typeof ChatScheduledTasksRoute '/settings': typeof ChatSettingsRouteWithChildren '/usage': typeof ChatUsageRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -339,6 +347,7 @@ export interface FileRoutesById { '/welcome': typeof WelcomeRoute '/_chat/new': typeof ChatNewRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute + '/_chat/scheduled-tasks': typeof ChatScheduledTasksRoute '/_chat/settings': typeof ChatSettingsRouteWithChildren '/_chat/usage': typeof ChatUsageRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -382,6 +391,7 @@ export interface FileRouteTypes { | '/welcome' | '/new' | '/pull-requests' + | '/scheduled-tasks' | '/settings' | '/usage' | '/projects/$projectKey' @@ -421,6 +431,7 @@ export interface FileRouteTypes { | '/welcome' | '/new' | '/pull-requests' + | '/scheduled-tasks' | '/settings' | '/usage' | '/projects/$projectKey' @@ -462,6 +473,7 @@ export interface FileRouteTypes { | '/welcome' | '/_chat/new' | '/_chat/pull-requests' + | '/_chat/scheduled-tasks' | '/_chat/settings' | '/_chat/usage' | '/projects/$projectKey' @@ -577,6 +589,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatSettingsRouteImport parentRoute: typeof ChatRoute } + '/_chat/scheduled-tasks': { + id: '/_chat/scheduled-tasks' + path: '/scheduled-tasks' + fullPath: '/scheduled-tasks' + preLoaderRoute: typeof ChatScheduledTasksRouteImport + parentRoute: typeof ChatRoute + } '/_chat/pull-requests': { id: '/_chat/pull-requests' path: '/pull-requests' @@ -846,6 +865,7 @@ const ChatSettingsRouteWithChildren = ChatSettingsRoute._addFileChildren( interface ChatRouteChildren { ChatNewRoute: typeof ChatNewRoute ChatPullRequestsRoute: typeof ChatPullRequestsRoute + ChatScheduledTasksRoute: typeof ChatScheduledTasksRoute ChatSettingsRoute: typeof ChatSettingsRouteWithChildren ChatUsageRoute: typeof ChatUsageRoute ChatIndexRoute: typeof ChatIndexRoute @@ -856,6 +876,7 @@ interface ChatRouteChildren { const ChatRouteChildren: ChatRouteChildren = { ChatNewRoute: ChatNewRoute, ChatPullRequestsRoute: ChatPullRequestsRoute, + ChatScheduledTasksRoute: ChatScheduledTasksRoute, ChatSettingsRoute: ChatSettingsRouteWithChildren, ChatUsageRoute: ChatUsageRoute, ChatIndexRoute: ChatIndexRoute, diff --git a/apps/web/src/routes/_chat.scheduled-tasks.tsx b/apps/web/src/routes/_chat.scheduled-tasks.tsx new file mode 100644 index 0000000000000..9cc87e593ef0b --- /dev/null +++ b/apps/web/src/routes/_chat.scheduled-tasks.tsx @@ -0,0 +1,53 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { AppRouteModal, AppRouteModalClose } from "../components/AppRouteModal"; +import { ScheduledTasksSettings } from "../components/settings/ScheduledTasksSettings"; +import { SettingsScopeProvider } from "../components/settings/SettingsScopeContext"; +import { validateScheduledTasksSearch } from "../components/settings/scheduledTasksSettings.logic"; +import { validateSettingsRouteSearch } from "../components/settings/settingsScopeNavigation"; +import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem } from "../components/WorkspaceBreadcrumb"; + +function ScheduledTasksRoute() { + const { project, machine, checkout, ...target } = Route.useSearch(); + const navigate = Route.useNavigate(); + return ( + +

+
+ + +

Scheduled tasks

+
+
+
+ +
+
+ { + // Replace every key, so a new filter also drops the task link and its environment. + void navigate({ + search: () => ({ + project: next.project, + machine: next.machine, + checkout: next.checkout, + }), + resetScroll: false, + }); + }} + > + + +
+ + ); +} + +export const Route = createFileRoute("/_chat/scheduled-tasks")({ + validateSearch: (raw: Record) => ({ + ...validateSettingsRouteSearch(raw), + ...validateScheduledTasksSearch(raw), + }), + component: ScheduledTasksRoute, +}); diff --git a/apps/web/src/shortcuts/commandPaletteCommands.ts b/apps/web/src/shortcuts/commandPaletteCommands.ts index 716717061e868..7bb000de4e4bb 100644 --- a/apps/web/src/shortcuts/commandPaletteCommands.ts +++ b/apps/web/src/shortcuts/commandPaletteCommands.ts @@ -92,6 +92,11 @@ export function installCommandPaletteCommands( dispatchCommandPalette({ _tag: "SetOpen", open: false }); void router.navigate({ to: "/usage" }); }, + "scheduledTasks.open": (event) => { + consume(event); + dispatchCommandPalette({ _tag: "SetOpen", open: false }); + void router.navigate({ to: "/scheduled-tasks" }); + }, "commandPalette.toggle": toggleOverlay("commandPalette.toggle"), "filePicker.toggle": toggleOverlay("filePicker.toggle"), "projectSearch.toggle": toggleOverlay("projectSearch.toggle"), diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 718f8723468a0..bb824fc5432d5 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -60,13 +60,17 @@ to override its environment defaults. Worktree directories keep their original n If generation fails, or a custom name is invalid or already taken, the temporary branch name remains. -## Scheduled tasks on mobile +## Scheduled tasks Open **Settings → Scheduled tasks** to create recurring tasks or manage existing ones across your connected environments. Use the settings filter to narrow the list by environment or project. Each task runs on the environment you choose, using its project, model, and workspace settings. Fixed-time schedules use that -environment's time zone, which may differ from your phone's. +environment's time zone, which may differ from your device's. + +On web and desktop, you can also open **Scheduled tasks** from the sidebar or the +command palette. To open it with a shortcut, bind **Scheduled Tasks: Open** in +**Settings → Keybindings**; it has no default. Deleting a thread also deletes tasks that post into that thread. Tasks that start a new thread on each run survive deletion of their previous threads. diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 847ed0746c1ee..115624b5e253b 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -83,6 +83,7 @@ export const STATIC_KEYBINDING_COMMANDS = [ "filePicker.toggle", "projectSearch.toggle", "usage.open", + "scheduledTasks.open", "theme.select", "appearance.cycle", "themeEditor.toggle",