Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
013edda
feat(frontend): replace default workspace look with classic UI
LittleChenLiya Aug 18, 2026
8d3b1b2
feat(frontend): add observatory workspace skin
LittleChenLiya Aug 19, 2026
16422d1
fix(frontend): remove observatory sky-toggle button from workspace he…
LittleChenLiya Aug 20, 2026
6426419
chore: checkpoint current frontend UI rework (WIP checkpoint)
LittleChenLiya Aug 20, 2026
0f2de46
feat(frontend): classic skin + UI rework (observatory stripped)
LittleChenLiya Aug 20, 2026
72b9561
fix(frontend): constrain classic skin card width in appearance settings
LittleChenLiya Aug 20, 2026
cf3c545
style(frontend): prettier format for CI
LittleChenLiya Aug 20, 2026
85b2ba0
chore(frontend): remove stray scratch files from checkpoint
LittleChenLiya Aug 20, 2026
30e8435
refactor(frontend): remove dead observatory i18n keys from PR1
LittleChenLiya Aug 20, 2026
e863f95
refactor(frontend): scheduled tasks page to card grid + detail dialog
LittleChenLiya Aug 20, 2026
441c807
fix(frontend): address review findings - destructive contrast, chats …
LittleChenLiya Aug 20, 2026
d26a7fd
feat(frontend): show icon header on create scheduled task page
LittleChenLiya Aug 20, 2026
1a5f32e
fix(frontend): restore destructive contrast in dark theme
LittleChenLiya Aug 20, 2026
3b030e3
refactor(frontend): remove dead css vars, redundant header wrapper, d…
LittleChenLiya Aug 21, 2026
d44673a
refactor(frontend): move skin scaffolding out of PR1 (SkinProvider/Pa…
LittleChenLiya Aug 21, 2026
a4e03d2
refactor(frontend): split out non-skin extras (chats settings page, z…
LittleChenLiya Aug 21, 2026
9cb7f4e
style(frontend): vertically center new scheduled task form
LittleChenLiya Aug 21, 2026
158be8c
fix(frontend): address review feedback on scheduled-tasks redesign an…
LittleChenLiya Aug 21, 2026
4f14ad9
refactor(frontend): remove orphaned memory/workspace i18n keys and re…
LittleChenLiya Aug 21, 2026
e433ad0
feat(frontend): timezone dropdown with common+all zones, and e2e fixes
LittleChenLiya Aug 21, 2026
c46d4a8
fix(frontend): forward thread_id to create page, extract run-at valid…
LittleChenLiya Aug 22, 2026
6fae72c
fix(frontend): guard edit submit against empty schedule spec, announc…
LittleChenLiya Aug 22, 2026
acca5a2
fix(frontend): gate empty state on query pending, drop dead form-erro…
LittleChenLiya Aug 22, 2026
112be92
fix(frontend): use nullish coalescing for last task error fallback
LittleChenLiya Aug 22, 2026
49b5ad7
fix(frontend): drop orphan fillRequired key, mirror invalidOnce hint …
LittleChenLiya Aug 22, 2026
4c22a5e
refactor(frontend): drop orphan lastRunId i18n key
LittleChenLiya Aug 22, 2026
78f3d19
fix(frontend): restore reuse-thread context notice after rebase onto …
LittleChenLiya Aug 24, 2026
91a0872
test(frontend): close detail dialog before navigating to create page …
LittleChenLiya Aug 24, 2026
d8da426
fix(frontend): preserve thread_id on create exits, label back button,…
LittleChenLiya Aug 24, 2026
bd73174
fix(frontend): return created scheduled tasks to a list that can cont…
LittleChenLiya Aug 24, 2026
bbd2aae
merge origin/main into feat/frontend-ui-rework
LittleChenLiya Aug 25, 2026
eb7ee01
merge origin/main and port scheduled-task duplication
LittleChenLiya Aug 28, 2026
b5f19c0
Merge remote-tracking branch 'origin/main' into feat/frontend-ui-rework
LittleChenLiya Aug 30, 2026
bbf1096
fix(frontend): keep scheduled-task duplicate drafts out of URLs
LittleChenLiya Aug 30, 2026
e11d5fa
Merge remote-tracking branch 'origin/main' into feat/frontend-ui-rework
LittleChenLiya Aug 30, 2026
05a1fe0
Merge remote-tracking branch 'origin/main' into feat/frontend-ui-rework
LittleChenLiya Aug 30, 2026
78dd7a5
fix(frontend): restore describeSchedule coverage dropped in rebase fa…
LittleChenLiya Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
308 changes: 308 additions & 0 deletions frontend/src/app/workspace/scheduled-tasks/new/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,308 @@
"use client";

import { useQueryClient } from "@tanstack/react-query";
import { ArrowLeftIcon, CalendarClock, TriangleAlertIcon } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";

import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
ScheduledTaskScheduleInput,
type ScheduleValue,
} from "@/components/workspace/scheduled-task-schedule-input";
import { useI18n } from "@/core/i18n/hooks";
import { fetchScheduledTasks } from "@/core/scheduled-tasks/api";
import {
clearDuplicateDraft,
draftFromScheduledTask,
getSessionDuplicateDraftStorage,
readDuplicateDraft,
resolveCreateContextMode,
type DuplicateDraft,
} from "@/core/scheduled-tasks/duplicate-draft";
import { useCreateScheduledTask } from "@/core/scheduled-tasks/hooks";
import type { ScheduledTask } from "@/core/scheduled-tasks/types";

function ReuseThreadNotice({
title,
description,
}: {
title: string;
description: string;
}) {
return (
<Alert className="border-amber-500/50 bg-amber-500/10">
<TriangleAlertIcon className="text-amber-600 dark:text-amber-400" />
<AlertTitle>{title}</AlertTitle>
<AlertDescription>{description}</AlertDescription>
</Alert>
);
}

function scheduleFromDraft(draft: DuplicateDraft): ScheduleValue {
const spec = draft.schedule_spec as { cron?: string; run_at?: string };
if (draft.schedule_type === "once") {
return {
schedule_type: "once",
schedule_spec: spec.run_at ? { run_at: spec.run_at } : {},
timezone: draft.timezone ?? "",
};
}
return {
schedule_type: "cron",
schedule_spec: { cron: spec.cron ?? "0 9 * * *" },
timezone: draft.timezone ?? "",
};
}

export default function NewScheduledTaskPage() {
const { t } = useI18n();
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const sourceTaskId = searchParams.get("from");
const initialThreadId = searchParams.get("thread_id");
const initialContextMode = searchParams.get("context_mode");
const st = t.scheduledTasks;
const createTask = useCreateScheduledTask();
const [contextMode, setContextMode] = useState<
"fresh_thread_per_run" | "reuse_thread"
>(() =>
resolveCreateContextMode({
contextModeParam: initialContextMode,
threadIdParam: sourceTaskId ? null : initialThreadId,
}),
);
const [targetThreadId, setTargetThreadId] = useState(
sourceTaskId ? "" : (initialThreadId ?? ""),
);
const [title, setTitle] = useState("");
const [prompt, setPrompt] = useState("");
const [createSchedule, setCreateSchedule] = useState<ScheduleValue>({
schedule_type: "cron",
schedule_spec: { cron: "0 9 * * *" },
timezone: "",
});
const [sourceStatus, setSourceStatus] = useState<
"idle" | "loading" | "ready" | "missing"
>(sourceTaskId ? "loading" : "idle");

const applyDraft = (draft: DuplicateDraft) => {
setTitle(draft.title);
setPrompt(draft.prompt);
setContextMode(draft.context_mode);
setTargetThreadId(draft.thread_id ?? "");
setCreateSchedule(scheduleFromDraft(draft));
setSourceStatus("ready");
};

useEffect(() => {
if (!sourceTaskId) {
return;
}
let cancelled = false;
const applyIfCurrent = (draft: DuplicateDraft) => {
if (!cancelled) {
applyDraft(draft);
}
};

const sessionDraft = readDuplicateDraft(
getSessionDuplicateDraftStorage(),
sourceTaskId,
);
if (sessionDraft) {
applyIfCurrent(sessionDraft);
clearDuplicateDraft(getSessionDuplicateDraftStorage(), sourceTaskId);
return () => {
cancelled = true;
};
}

const cached = queryClient
.getQueryData<ScheduledTask[]>(["scheduled-tasks"])
?.find((task) => task.id === sourceTaskId);
if (cached) {
applyIfCurrent(
draftFromScheduledTask(cached, st.actions.duplicateTitleSuffix),
);
return () => {
cancelled = true;
};
}

void fetchScheduledTasks()
.then((tasks) => {
const found = tasks.find((task) => task.id === sourceTaskId);
if (!found) {
if (!cancelled) {
setSourceStatus("missing");
}
return;
}
applyIfCurrent(
draftFromScheduledTask(found, st.actions.duplicateTitleSuffix),
);
})
.catch(() => {
if (!cancelled) {
setSourceStatus("missing");
}
});

return () => {
cancelled = true;
};
}, [queryClient, sourceTaskId, st.actions.duplicateTitleSuffix]);

const listHref = initialThreadId
? `/workspace/scheduled-tasks?thread_id=${encodeURIComponent(initialThreadId)}`
: "/workspace/scheduled-tasks";

const successHref =
contextMode === "reuse_thread" && targetThreadId
? `/workspace/scheduled-tasks?thread_id=${encodeURIComponent(targetThreadId)}`
: "/workspace/scheduled-tasks";

const handleCreate = () => {
createTask.mutate(
{
context_mode: contextMode,
thread_id: contextMode === "reuse_thread" ? targetThreadId : null,
title,
prompt,
schedule_type: createSchedule.schedule_type,
schedule_spec: createSchedule.schedule_spec,
timezone: createSchedule.timezone || "UTC",
},
{ onSuccess: () => router.push(successHref) },
);
};

return (
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
<div className="flex items-center gap-3">
<Button
variant="ghost"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Give the back button an accessible name

This icon-only navigation button has no aria-label or other accessible name, so assistive technology announces it only as an unnamed button. Please add a localized label such as “Back to scheduled tasks” (and optionally a matching title).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d8da426. The icon-only back button now has a localized aria-label/title (st.create.back: "Back to scheduled tasks" / "返回定时任务").

size="icon-sm"
aria-label={st.create.back}
title={st.create.back}
onClick={() => router.push(listHref)}
>
<ArrowLeftIcon className="h-4 w-4" />
</Button>
<h1 className="text-sm font-semibold">{st.create.title}</h1>
</div>
</header>
<main className="flex flex-1 justify-center overflow-y-auto px-4 py-6">
<div
className="my-auto w-full max-w-xl space-y-4"
data-testid="scheduled-task-create-form"
>
<div className="space-y-3 pb-2 text-center">
<div className="bg-primary/10 mx-auto flex h-14 w-14 items-center justify-center rounded-full">
<CalendarClock className="text-primary h-7 w-7" />
</div>
<div className="space-y-1">
<h2 className="text-xl font-semibold">{st.create.title}</h2>
<p className="text-muted-foreground text-sm">{st.description}</p>
</div>
</div>
{sourceStatus === "missing" && (
<div className="text-destructive text-sm">
{st.create.sourceMissing}
</div>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant={
contextMode === "fresh_thread_per_run" ? "default" : "outline"
}
size="sm"
onClick={() => setContextMode("fresh_thread_per_run")}
>
{st.context.fresh}
</Button>
<Button
type="button"
variant={contextMode === "reuse_thread" ? "default" : "outline"}
size="sm"
onClick={() => setContextMode("reuse_thread")}
>
{st.context.reuse}
</Button>
</div>
{contextMode === "reuse_thread" && (
<>
<Input
value={targetThreadId}
onChange={(event) => setTargetThreadId(event.target.value)}
placeholder={st.context.threadIdPlaceholder}
/>
<ReuseThreadNotice
title={st.context.reuseNoticeTitle}
description={st.context.reuseNoticeDescription}
/>
</>
)}
<Input
autoFocus
value={title}
onChange={(event) => setTitle(event.target.value)}
placeholder={st.create.taskTitle}
/>
<Textarea
rows={4}
value={prompt}
onChange={(event) => setPrompt(event.target.value)}
placeholder={st.create.prompt}
/>
<ScheduledTaskScheduleInput
key={
sourceTaskId
? `${sourceTaskId}:${createSchedule.schedule_type}:${JSON.stringify(createSchedule.schedule_spec)}`
: "new"
}
initial={createSchedule}
onChange={setCreateSchedule}
/>
{createSchedule.schedule_type === "once" &&
!createSchedule.schedule_spec.run_at && (
<div className="text-muted-foreground text-sm">
{st.create.invalidOnce}
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => router.push(listHref)}
disabled={createTask.isPending}
>
{t.common.cancel}
</Button>
<Button
onClick={handleCreate}
disabled={
sourceStatus === "loading" ||
sourceStatus === "missing" ||
!title ||
!prompt ||
(!createSchedule.schedule_spec.cron &&
!createSchedule.schedule_spec.run_at) ||
(contextMode === "reuse_thread" && !targetThreadId) ||
createTask.isPending
}
>
{createTask.isPending ? t.common.loading : st.create.submit}
</Button>
</div>
</div>
</main>
</div>
);
}
Loading
Loading