diff --git a/src/app/projects/[projectId]/components/chatForm/index.ts b/src/app/projects/[projectId]/components/chatForm/index.ts index ca75a3b..bb31081 100644 --- a/src/app/projects/[projectId]/components/chatForm/index.ts +++ b/src/app/projects/[projectId]/components/chatForm/index.ts @@ -8,3 +8,5 @@ export { useContinueSessionProcessMutation, useCreateSessionProcessMutation, } from "./useChatMutations"; +export type { UseMessageCompletionResult } from "./useMessageCompletion"; +export { useMessageCompletion } from "./useMessageCompletion"; diff --git a/src/app/projects/[projectId]/components/chatForm/useMessageCompletion.ts b/src/app/projects/[projectId]/components/chatForm/useMessageCompletion.ts new file mode 100644 index 0000000..fdb86c0 --- /dev/null +++ b/src/app/projects/[projectId]/components/chatForm/useMessageCompletion.ts @@ -0,0 +1,161 @@ +import { useCallback, useRef, useState } from "react"; +import type { CommandCompletionRef } from "./CommandCompletion"; +import type { FileCompletionRef } from "./FileCompletion"; + +export interface UseMessageCompletionResult { + cursorPosition: { + relative: { top: number; left: number }; + absolute: { top: number; left: number }; + }; + containerRef: React.RefObject; + textareaRef: React.RefObject; + commandCompletionRef: React.RefObject; + fileCompletionRef: React.RefObject; + getCursorPosition: () => + | { + relative: { top: number; left: number }; + absolute: { top: number; left: number }; + } + | undefined; + handleChange: (value: string, onChange: (value: string) => void) => void; + handleKeyDown: (e: React.KeyboardEvent) => boolean; + handleCommandSelect: ( + command: string, + onSelect: (command: string) => void, + ) => void; + handleFileSelect: ( + filePath: string, + onSelect: (filePath: string) => void, + ) => void; +} + +/** + * Message input with command and file completion support + */ +export function useMessageCompletion(): UseMessageCompletionResult { + const [cursorPosition, setCursorPosition] = useState<{ + relative: { top: number; left: number }; + absolute: { top: number; left: number }; + }>({ relative: { top: 0, left: 0 }, absolute: { top: 0, left: 0 } }); + + const containerRef = useRef(null); + const textareaRef = useRef(null); + const commandCompletionRef = useRef(null); + const fileCompletionRef = useRef(null); + + const getCursorPosition = useCallback(() => { + const textarea = textareaRef.current; + const container = containerRef.current; + if (textarea === null || container === null) return undefined; + + const cursorPos = textarea.selectionStart; + const textBeforeCursor = textarea.value.substring(0, cursorPos); + const textAfterCursor = textarea.value.substring(cursorPos); + + const pre = document.createTextNode(textBeforeCursor); + const post = document.createTextNode(textAfterCursor); + const caret = document.createElement("span"); + caret.innerHTML = " "; + + const mirrored = document.createElement("div"); + + mirrored.innerHTML = ""; + mirrored.append(pre, caret, post); + + const textareaStyles = window.getComputedStyle(textarea); + for (const property of [ + "border", + "boxSizing", + "fontFamily", + "fontSize", + "fontWeight", + "letterSpacing", + "lineHeight", + "padding", + "textDecoration", + "textIndent", + "textTransform", + "whiteSpace", + "wordSpacing", + "wordWrap", + ] as const) { + mirrored.style[property] = textareaStyles[property]; + } + + mirrored.style.visibility = "hidden"; + container.prepend(mirrored); + + const caretRect = caret.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + container.removeChild(mirrored); + + return { + relative: { + top: caretRect.top - containerRect.top - textarea.scrollTop, + left: caretRect.left - containerRect.left - textarea.scrollLeft, + }, + absolute: { + top: caretRect.top - textarea.scrollTop, + left: caretRect.left - textarea.scrollLeft, + }, + }; + }, []); + + const handleChange = useCallback( + (value: string, onChange: (value: string) => void) => { + if (value.endsWith("@") || value.endsWith("/")) { + const position = getCursorPosition(); + if (position) { + setCursorPosition(position); + } + } + onChange(value); + }, + [getCursorPosition], + ); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent): boolean => { + if (fileCompletionRef.current?.handleKeyDown(e)) { + return true; + } + + if (commandCompletionRef.current?.handleKeyDown(e)) { + return true; + } + + return false; + }, + [], + ); + + const handleCommandSelect = useCallback( + (command: string, onSelect: (command: string) => void) => { + onSelect(command); + textareaRef.current?.focus(); + }, + [], + ); + + const handleFileSelect = useCallback( + (filePath: string, onSelect: (filePath: string) => void) => { + onSelect(filePath); + textareaRef.current?.focus(); + }, + [], + ); + + return { + cursorPosition, + containerRef, + textareaRef, + commandCompletionRef, + fileCompletionRef, + getCursorPosition, + handleChange, + handleKeyDown, + handleCommandSelect, + handleFileSelect, + }; +} diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx new file mode 100644 index 0000000..ad64133 --- /dev/null +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SchedulerTab.tsx @@ -0,0 +1,364 @@ +"use client"; + +import { Trans, useLingui } from "@lingui/react"; +import { EditIcon, PlusIcon, RefreshCwIcon, TrashIcon } from "lucide-react"; +import { type FC, useState } from "react"; +import { toast } from "sonner"; +import { SchedulerJobDialog } from "@/components/scheduler/SchedulerJobDialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + type NewSchedulerJob, + type SchedulerJob, + useCreateSchedulerJob, + useDeleteSchedulerJob, + useSchedulerJobs, + useUpdateSchedulerJob, +} from "@/hooks/useScheduler"; + +export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({ + projectId, + sessionId, +}) => { + const { i18n } = useLingui(); + const { data: jobs, isLoading, error, refetch } = useSchedulerJobs(); + const createJob = useCreateSchedulerJob(); + const updateJob = useUpdateSchedulerJob(); + const deleteJob = useDeleteSchedulerJob(); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingJob, setEditingJob] = useState(null); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [deletingJobId, setDeletingJobId] = useState(null); + + const handleCreateJob = (job: NewSchedulerJob) => { + createJob.mutate(job, { + onSuccess: () => { + toast.success( + i18n._({ + id: "scheduler.job.created", + message: "Job created successfully", + }), + ); + setDialogOpen(false); + }, + onError: (error) => { + toast.error( + i18n._({ + id: "scheduler.job.create_failed", + message: "Failed to create job", + }), + { + description: error.message, + }, + ); + }, + }); + }; + + const handleUpdateJob = (job: NewSchedulerJob) => { + if (!editingJob) return; + + updateJob.mutate( + { + id: editingJob.id, + updates: job, + }, + { + onSuccess: () => { + toast.success( + i18n._({ + id: "scheduler.job.updated", + message: "Job updated successfully", + }), + ); + setDialogOpen(false); + setEditingJob(null); + }, + onError: (error) => { + toast.error( + i18n._({ + id: "scheduler.job.update_failed", + message: "Failed to update job", + }), + { + description: error.message, + }, + ); + }, + }, + ); + }; + + const handleDeleteConfirm = () => { + if (!deletingJobId) return; + + deleteJob.mutate(deletingJobId, { + onSuccess: () => { + toast.success( + i18n._({ + id: "scheduler.job.deleted", + message: "Job deleted successfully", + }), + ); + setDeleteDialogOpen(false); + setDeletingJobId(null); + }, + onError: (error) => { + toast.error( + i18n._({ + id: "scheduler.job.delete_failed", + message: "Failed to delete job", + }), + { + description: error.message, + }, + ); + }, + }); + }; + + const handleEditClick = (job: SchedulerJob) => { + setEditingJob(job); + setDialogOpen(true); + }; + + const handleDeleteClick = (jobId: string) => { + setDeletingJobId(jobId); + setDeleteDialogOpen(true); + }; + + const formatSchedule = (job: SchedulerJob) => { + if (job.schedule.type === "cron") { + return `Cron: ${job.schedule.expression}`; + } + const hours = Math.floor(job.schedule.delayMs / 3600000); + const minutes = Math.floor((job.schedule.delayMs % 3600000) / 60000); + const timeStr = + hours > 0 + ? `${hours}h ${minutes}m` + : minutes > 0 + ? `${minutes}m` + : `${job.schedule.delayMs}ms`; + return `${job.schedule.oneTime ? "Once" : "Recurring"}: ${timeStr}`; + }; + + const formatLastRun = (lastRunAt: string | null) => { + if (!lastRunAt) return "Never"; + const date = new Date(lastRunAt); + return date.toLocaleString(); + }; + + return ( +
+
+
+

+ +

+
+ + +
+
+
+ +
+ {isLoading && ( +
+
+ +
+
+ )} + + {error && ( +
+ +
+ )} + + {jobs && jobs.length === 0 && ( +
+ +
+ )} + + {jobs && jobs.length > 0 && ( +
+ {jobs.map((job) => ( +
+
+
+
+

+ {job.name} +

+ + {job.enabled ? ( + + ) : ( + + )} + +
+

+ {formatSchedule(job)} +

+
+
+ + +
+
+ + {job.lastRunAt && ( +
+
+ + + {formatLastRun(job.lastRunAt)} + + {job.lastRunStatus && ( + + {job.lastRunStatus} + + )} +
+
+ )} +
+ ))} +
+ )} +
+ + {/* Create/Edit Dialog */} + { + setDialogOpen(open); + if (!open) setEditingJob(null); + }} + job={editingJob} + projectId={projectId} + currentSessionId={sessionId} + onSubmit={editingJob ? handleUpdateJob : handleCreateJob} + isSubmitting={createJob.isPending || updateJob.isPending} + /> + + {/* Delete Confirmation Dialog */} + + + + + + + + + + + + + + + + +
+ ); +}; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index 13bf3f7..2bf5eb2 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -1,7 +1,12 @@ "use client"; import { Trans } from "@lingui/react"; -import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react"; +import { + ArrowLeftIcon, + CalendarClockIcon, + MessageSquareIcon, + PlugIcon, +} from "lucide-react"; import Link from "next/link"; import { type FC, useMemo } from "react"; import type { SidebarTab } from "@/components/GlobalSidebar"; @@ -16,6 +21,7 @@ import { cn } from "@/lib/utils"; import { useProject } from "../../../../hooks/useProject"; import { McpTab } from "./McpTab"; import { MobileSidebar } from "./MobileSidebar"; +import { SchedulerTab } from "./SchedulerTab"; import { SessionsTab } from "./SessionsTab"; export const SessionSidebar: FC<{ @@ -65,6 +71,14 @@ export const SessionSidebar: FC<{ title: "Show MCP server settings", content: , }, + { + id: "scheduler", + icon: CalendarClockIcon, + title: "Show scheduler jobs", + content: ( + + ), + }, ], [ sessions, diff --git a/src/components/scheduler/CronExpressionBuilder.tsx b/src/components/scheduler/CronExpressionBuilder.tsx new file mode 100644 index 0000000..eb76cd0 --- /dev/null +++ b/src/components/scheduler/CronExpressionBuilder.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { Trans } from "@lingui/react"; +import { useEffect, useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type CronMode = "hourly" | "daily" | "weekly" | "custom"; + +interface CronExpressionBuilderProps { + value: string; + onChange: (expression: string) => void; +} + +interface ParsedCron { + mode: CronMode; + hour: number; + minute: number; + dayOfWeek: number; +} + +const WEEKDAYS = [ + { + value: 0, + labelKey: , + }, + { + value: 1, + labelKey: , + }, + { + value: 2, + labelKey: , + }, + { + value: 3, + labelKey: , + }, + { + value: 4, + labelKey: , + }, + { + value: 5, + labelKey: , + }, + { + value: 6, + labelKey: , + }, +]; + +function parseCronExpression(expression: string): ParsedCron | null { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return null; + + const minute = parts[0]; + const hour = parts[1]; + const dayOfWeek = parts[4]; + + if (!minute || !hour || !dayOfWeek) return null; + + // Hourly: "0 * * * *" + if (hour === "*" && minute === "0") { + return { mode: "hourly", hour: 0, minute: 0, dayOfWeek: 0 }; + } + + // Daily: "0 9 * * *" + if (dayOfWeek === "*" && hour !== "*") { + const h = Number.parseInt(hour, 10); + const m = Number.parseInt(minute, 10); + if (!Number.isNaN(h) && !Number.isNaN(m)) { + return { mode: "daily", hour: h, minute: m, dayOfWeek: 0 }; + } + } + + // Weekly: "0 9 * * 1" + if (dayOfWeek !== "*" && hour !== "*") { + const h = Number.parseInt(hour, 10); + const m = Number.parseInt(minute, 10); + const dow = Number.parseInt(dayOfWeek, 10); + if (!Number.isNaN(h) && !Number.isNaN(m) && !Number.isNaN(dow)) { + return { mode: "weekly", hour: h, minute: m, dayOfWeek: dow }; + } + } + + return { mode: "custom", hour: 0, minute: 0, dayOfWeek: 0 }; +} + +function buildCronExpression( + mode: CronMode, + hour: number, + minute: number, + dayOfWeek: number, +): string { + switch (mode) { + case "hourly": + return "0 * * * *"; + case "daily": + return `${minute} ${hour} * * *`; + case "weekly": + return `${minute} ${hour} * * ${dayOfWeek}`; + case "custom": + return "0 0 * * *"; + } +} + +function validateCronExpression(expression: string): boolean { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return false; + + const minute = parts[0]; + const hour = parts[1]; + const dayOfMonth = parts[2]; + const month = parts[3]; + const dayOfWeek = parts[4]; + + if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) return false; + + const isValidField = (field: string, min: number, max: number): boolean => { + if (field === "*") return true; + const num = Number.parseInt(field, 10); + return !Number.isNaN(num) && num >= min && num <= max; + }; + + return ( + isValidField(minute, 0, 59) && + (hour === "*" || isValidField(hour, 0, 23)) && + (dayOfMonth === "*" || isValidField(dayOfMonth, 1, 31)) && + (month === "*" || isValidField(month, 1, 12)) && + (dayOfWeek === "*" || isValidField(dayOfWeek, 0, 6)) + ); +} + +function getNextExecutionPreview(expression: string): React.ReactNode { + const parts = expression.trim().split(/\s+/); + if (parts.length !== 5) return "Invalid cron expression"; + + const minute = parts[0]; + const hour = parts[1]; + const dayOfWeek = parts[4]; + + if (!minute || !hour || !dayOfWeek) return "Invalid cron expression"; + + if (hour === "*") { + return `Every hour at ${minute} minute(s)`; + } + + const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`; + + if (dayOfWeek === "*") { + return `Every day at ${timeStr}`; + } + + const dow = Number.parseInt(dayOfWeek, 10); + const dayName = WEEKDAYS.find((d) => d.value === dow); + return ( + <> + Every {dayName ? dayName.labelKey : "unknown"} at {timeStr} + + ); +} + +export function CronExpressionBuilder({ + value, + onChange, +}: CronExpressionBuilderProps) { + const parsed = parseCronExpression(value); + + const [mode, setMode] = useState(parsed?.mode || "daily"); + const [hour, setHour] = useState(parsed?.hour || 9); + const [minute, setMinute] = useState(parsed?.minute || 0); + const [dayOfWeek, setDayOfWeek] = useState(parsed?.dayOfWeek || 1); + const [customExpression, setCustomExpression] = useState(value); + const [error, setError] = useState(null); + + useEffect(() => { + if (mode === "custom") { + if (validateCronExpression(customExpression)) { + onChange(customExpression); + setError(null); + } else { + setError("Invalid cron expression"); + } + } else { + const expr = buildCronExpression(mode, hour, minute, dayOfWeek); + onChange(expr); + setCustomExpression(expr); + setError(null); + } + }, [mode, hour, minute, dayOfWeek, customExpression, onChange]); + + const handleModeChange = (newMode: CronMode) => { + setMode(newMode); + if (newMode !== "custom") { + const expr = buildCronExpression(newMode, hour, minute, dayOfWeek); + setCustomExpression(expr); + } + }; + + return ( +
+
+ + +
+ + {mode === "daily" && ( +
+
+ + setHour(Number.parseInt(e.target.value, 10))} + /> +
+
+ + setMinute(Number.parseInt(e.target.value, 10))} + /> +
+
+ )} + + {mode === "weekly" && ( +
+
+ + +
+
+
+ + setHour(Number.parseInt(e.target.value, 10))} + /> +
+
+ + setMinute(Number.parseInt(e.target.value, 10))} + /> +
+
+
+ )} + + {mode === "custom" && ( +
+ + setCustomExpression(e.target.value)} + placeholder="0 9 * * *" + /> +
+ )} + +
+
+ +
+
+ {error ? ( + {error} + ) : ( + getNextExecutionPreview( + mode === "custom" ? customExpression : value, + ) + )} +
+
+ :{" "} + {mode === "custom" ? customExpression : value} +
+
+
+ ); +} diff --git a/src/components/scheduler/SchedulerJobDialog.tsx b/src/components/scheduler/SchedulerJobDialog.tsx new file mode 100644 index 0000000..d044c00 --- /dev/null +++ b/src/components/scheduler/SchedulerJobDialog.tsx @@ -0,0 +1,421 @@ +"use client"; + +import { Trans, useLingui } from "@lingui/react"; +import { type FC, useCallback, useEffect, useState } from "react"; +import { InlineCompletion } from "@/app/projects/[projectId]/components/chatForm/InlineCompletion"; +import { useMessageCompletion } from "@/app/projects/[projectId]/components/chatForm/useMessageCompletion"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Textarea } from "@/components/ui/textarea"; +import type { + NewSchedulerJob, + SchedulerJob, +} from "@/server/core/scheduler/schema"; +import { CronExpressionBuilder } from "./CronExpressionBuilder"; + +export interface SchedulerJobDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + job: SchedulerJob | null; + projectId: string; + currentSessionId: string; + onSubmit: (job: NewSchedulerJob) => void; + isSubmitting?: boolean; +} + +type DelayUnit = "minutes" | "hours" | "days"; + +export const SchedulerJobDialog: FC = ({ + open, + onOpenChange, + job, + projectId, + onSubmit, + isSubmitting = false, +}) => { + const { _, i18n } = useLingui(); + + const [name, setName] = useState(""); + const [scheduleType, setScheduleType] = useState<"cron" | "fixed">("cron"); + const [cronExpression, setCronExpression] = useState("0 9 * * *"); + const [delayValue, setDelayValue] = useState(60); // 60 minutes default + const [delayUnit, setDelayUnit] = useState("minutes"); + const [messageContent, setMessageContent] = useState(""); + const [enabled, setEnabled] = useState(true); + const [concurrencyPolicy, setConcurrencyPolicy] = useState<"skip" | "run">( + "skip", + ); + + // Message completion hook + const completion = useMessageCompletion(); + + // Convert delay value and unit to milliseconds + const delayToMs = useCallback((value: number, unit: DelayUnit): number => { + switch (unit) { + case "minutes": + return value * 60 * 1000; + case "hours": + return value * 60 * 60 * 1000; + case "days": + return value * 24 * 60 * 60 * 1000; + } + }, []); + + // Convert milliseconds to delay value and unit + const msToDelay = useCallback( + (ms: number): { value: number; unit: DelayUnit } => { + const minutes = ms / (60 * 1000); + const hours = ms / (60 * 60 * 1000); + const days = ms / (24 * 60 * 60 * 1000); + + if (days >= 1 && days === Math.floor(days)) { + return { value: days, unit: "days" }; + } + if (hours >= 1 && hours === Math.floor(hours)) { + return { value: hours, unit: "hours" }; + } + return { value: minutes, unit: "minutes" }; + }, + [], + ); + + // Initialize form with job data when editing + useEffect(() => { + if (job) { + setName(job.name); + setScheduleType(job.schedule.type); + if (job.schedule.type === "cron") { + setCronExpression(job.schedule.expression); + } else { + const { value, unit } = msToDelay(job.schedule.delayMs); + setDelayValue(value); + setDelayUnit(unit); + } + setMessageContent(job.message.content); + setEnabled(job.enabled); + setConcurrencyPolicy(job.concurrencyPolicy); + } else { + // Reset form for new job + setName(""); + setScheduleType("cron"); + setCronExpression("0 9 * * *"); + setDelayValue(60); + setDelayUnit("minutes"); + setMessageContent(""); + setEnabled(true); + setConcurrencyPolicy("skip"); + } + }, [job, msToDelay]); + + const handleSubmit = () => { + const delayMs = delayToMs(delayValue, delayUnit); + const newJob: NewSchedulerJob = { + name, + schedule: + scheduleType === "cron" + ? { type: "cron", expression: cronExpression } + : { type: "fixed", delayMs, oneTime: true }, + message: { + content: messageContent, + projectId, + baseSessionId: null, + }, + enabled, + concurrencyPolicy, + }; + + onSubmit(newJob); + }; + + const isFormValid = name.trim() !== "" && messageContent.trim() !== ""; + + return ( + + + + + {job ? ( + + ) : ( + + )} + + + + + + +
+ {/* Enabled Toggle */} +
+
+ +

+ +

+
+ +
+ + {/* Job Name */} +
+ + setName(e.target.value)} + placeholder={_({ + id: "scheduler.form.name.placeholder", + message: "例: 日次レポート", + })} + disabled={isSubmitting} + /> +
+ + {/* Schedule Type */} +
+ + +
+ + {/* Schedule Configuration */} + {scheduleType === "cron" ? ( + + ) : ( +
+ +
+ + setDelayValue(Number.parseInt(e.target.value, 10)) + } + disabled={isSubmitting} + className="flex-1" + placeholder="60" + /> + +
+

+ +

+
+ )} + + {/* Message Content */} +
+ +
+