diff --git a/src/app/projects/[projectId]/components/chatForm/ChatInput.test.tsx b/src/app/projects/[projectId]/components/chatForm/ChatInput.test.tsx new file mode 100644 index 0000000..3fff75b --- /dev/null +++ b/src/app/projects/[projectId]/components/chatForm/ChatInput.test.tsx @@ -0,0 +1,97 @@ +import { describe, expect, it } from "vitest"; +import type { ChatInputProps } from "./ChatInput"; + +describe("ChatInput Props", () => { + it("should have correct type definition for enableScheduledSend", () => { + const props: ChatInputProps = { + projectId: "test-project", + onSubmit: async () => {}, + isPending: false, + placeholder: "Type your message...", + buttonText: "Send", + enableScheduledSend: true, + baseSessionId: null, + }; + + expect(props.enableScheduledSend).toBe(true); + expect(props.baseSessionId).toBe(null); + }); + + it("should allow enableScheduledSend to be undefined", () => { + const props: ChatInputProps = { + projectId: "test-project", + onSubmit: async () => {}, + isPending: false, + placeholder: "Type your message...", + buttonText: "Send", + }; + + expect(props.enableScheduledSend).toBeUndefined(); + }); + + it("should allow baseSessionId to be a string", () => { + const props: ChatInputProps = { + projectId: "test-project", + onSubmit: async () => {}, + isPending: false, + placeholder: "Type your message...", + buttonText: "Send", + baseSessionId: "session-123", + }; + + expect(props.baseSessionId).toBe("session-123"); + }); + + it("should validate datetime format parsing logic", () => { + const scheduledTime = "2025-10-26T15:30"; + const match = scheduledTime.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/, + ); + + expect(match).not.toBeNull(); + + if (match) { + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4]); + const minutes = Number(match[5]); + + expect(year).toBe(2025); + expect(month).toBe(10); + expect(day).toBe(26); + expect(hours).toBe(15); + expect(minutes).toBe(30); + + const localDate = new Date(year, month - 1, day, hours, minutes); + expect(localDate.getFullYear()).toBe(2025); + expect(localDate.getMonth()).toBe(9); // 0-indexed + expect(localDate.getDate()).toBe(26); + expect(localDate.getHours()).toBe(15); + expect(localDate.getMinutes()).toBe(30); + } + }); + + it("should handle invalid datetime format", () => { + const invalidTime = "invalid-datetime"; + const match = invalidTime.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/, + ); + + expect(match).toBeNull(); + }); + + it("should generate default scheduled time with correct format", () => { + const now = new Date(); + now.setHours(now.getHours() + 1); + const formatted = now.toISOString().slice(0, 16); + + // Verify format is correct (YYYY-MM-DDTHH:mm) + expect(formatted).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/); + + // Verify the format can be parsed back + const parsed = new Date(formatted); + expect(parsed).toBeInstanceOf(Date); + expect(Number.isNaN(parsed.getTime())).toBe(false); + }); +}); diff --git a/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx b/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx index e2924b1..0f14654 100644 --- a/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx +++ b/src/app/projects/[projectId]/components/chatForm/ChatInput.tsx @@ -8,8 +8,19 @@ import { XIcon, } from "lucide-react"; import { type FC, useCallback, useId, useRef, useState } from "react"; +import { toast } from "sonner"; import { Button } from "../../../../../components/ui/button"; +import { Input } from "../../../../../components/ui/input"; +import { Label } from "../../../../../components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../../../components/ui/select"; import { Textarea } from "../../../../../components/ui/textarea"; +import { useCreateSchedulerJob } from "../../../../../hooks/useScheduler"; import { useConfig } from "../../../../hooks/useConfig"; import type { CommandCompletionRef } from "./CommandCompletion"; import type { FileCompletionRef } from "./FileCompletion"; @@ -33,6 +44,8 @@ export interface ChatInputProps { containerClassName?: string; disabled?: boolean; buttonSize?: "sm" | "default" | "lg"; + enableScheduledSend?: boolean; + baseSessionId?: string | null; } export const ChatInput: FC = ({ @@ -46,6 +59,8 @@ export const ChatInput: FC = ({ containerClassName = "", disabled = false, buttonSize = "lg", + enableScheduledSend = false, + baseSessionId = null, }) => { const { i18n } = useLingui(); const [message, setMessage] = useState(""); @@ -56,6 +71,19 @@ export const ChatInput: FC = ({ relative: { top: number; left: number }; absolute: { top: number; left: number }; }>({ relative: { top: 0, left: 0 }, absolute: { top: 0, left: 0 } }); + const [sendMode, setSendMode] = useState<"immediate" | "scheduled">( + "immediate", + ); + const [scheduledTime, setScheduledTime] = useState(() => { + const now = new Date(); + now.setHours(now.getHours() + 1); + const year = now.getFullYear(); + const month = String(now.getMonth() + 1).padStart(2, "0"); + const day = String(now.getDate()).padStart(2, "0"); + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; + }); const containerRef = useRef(null); const textareaRef = useRef(null); @@ -64,6 +92,7 @@ export const ChatInput: FC = ({ const fileCompletionRef = useRef(null); const helpId = useId(); const { config } = useConfig(); + const createSchedulerJob = useCreateSchedulerJob(); const handleSubmit = async () => { if (!message.trim() && attachedFiles.length === 0) return; @@ -88,14 +117,73 @@ export const ChatInput: FC = ({ const finalText = message.trim() + additionalText; - await onSubmit({ - text: finalText, - images: images.length > 0 ? images : undefined, - documents: documents.length > 0 ? documents : undefined, - }); + if (enableScheduledSend && sendMode === "scheduled") { + // Create a scheduler job for scheduled send + const match = scheduledTime.match( + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/, + ); + if (!match) { + throw new Error("Invalid datetime format"); + } + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hours = Number(match[4]); + const minutes = Number(match[5]); + const localDate = new Date(year, month - 1, day, hours, minutes); - setMessage(""); - setAttachedFiles([]); + try { + await createSchedulerJob.mutateAsync({ + name: `Scheduled message at ${scheduledTime}`, + schedule: { + type: "reserved", + reservedExecutionTime: localDate.toISOString(), + }, + message: { + content: finalText, + projectId, + baseSessionId, + }, + enabled: true, + }); + + toast.success( + i18n._({ + id: "chat.scheduled_send.success", + message: "Message scheduled successfully", + }), + { + description: i18n._({ + id: "chat.scheduled_send.success_description", + message: "You can view and manage it in the Scheduler tab", + }), + }, + ); + + setMessage(""); + setAttachedFiles([]); + } catch (error) { + toast.error( + i18n._({ + id: "chat.scheduled_send.failed", + message: "Failed to schedule message", + }), + { + description: error instanceof Error ? error.message : undefined, + }, + ); + } + } else { + // Immediate send + await onSubmit({ + text: finalText, + images: images.length > 0 ? images : undefined, + documents: documents.length > 0 ? documents : undefined, + }); + + setMessage(""); + setAttachedFiles([]); + } }; const handleFileSelect = (e: React.ChangeEvent) => { @@ -338,33 +426,93 @@ export const ChatInput: FC = ({ )} - + + + { const behavior = config?.enterKeyBehavior; if (behavior === "enter-send") { - return i18n._( - "Type your message... (Start with / for commands, @ for files, Enter to send)", - ); + return i18n._({ + id: "chat.placeholder.continue.enter", + message: + "Type your message... (Start with / for commands, @ for files, Enter to send, or schedule for later)", + }); } if (behavior === "command-enter-send") { - return i18n._( - "Type your message... (Start with / for commands, @ for files, Command+Enter to send)", - ); + return i18n._({ + id: "chat.placeholder.continue.command_enter", + message: + "Type your message... (Start with / for commands, @ for files, Command+Enter to send, or schedule for later)", + }); } - return i18n._( - "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)", - ); + return i18n._({ + id: "chat.placeholder.continue.shift_enter", + message: + "Type your message... (Start with / for commands, @ for files, Shift+Enter to send, or schedule for later)", + }); }; const buttonText = ; @@ -56,6 +62,8 @@ export const ContinueChat: FC<{ minHeight="min-h-[120px]" containerClassName="" buttonSize="lg" + enableScheduledSend={true} + baseSessionId={sessionId} /> diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx index f14bb31..7056731 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx @@ -25,18 +25,24 @@ export const ResumeChat: FC<{ const getPlaceholder = () => { const behavior = config?.enterKeyBehavior; if (behavior === "enter-send") { - return i18n._( - "Type your message... (Start with / for commands, @ for files, Enter to send)", - ); + return i18n._({ + id: "chat.placeholder.resume.enter", + message: + "Type your message... (Start with / for commands, @ for files, Enter to send, or schedule for later)", + }); } if (behavior === "command-enter-send") { - return i18n._( - "Type your message... (Start with / for commands, @ for files, Command+Enter to send)", - ); + return i18n._({ + id: "chat.placeholder.resume.command_enter", + message: + "Type your message... (Start with / for commands, @ for files, Command+Enter to send, or schedule for later)", + }); } - return i18n._( - "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)", - ); + return i18n._({ + id: "chat.placeholder.resume.shift_enter", + message: + "Type your message... (Start with / for commands, @ for files, Shift+Enter to send, or schedule for later)", + }); }; const buttonText = ; @@ -55,6 +61,8 @@ export const ResumeChat: FC<{ minHeight="min-h-[120px]" containerClassName="" buttonSize="lg" + enableScheduledSend={true} + baseSessionId={sessionId} /> diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/scheduler/SchedulerJobDialog.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/scheduler/SchedulerJobDialog.tsx index 4fe4507..f2f08ba 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/scheduler/SchedulerJobDialog.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/scheduler/SchedulerJobDialog.tsx @@ -163,19 +163,19 @@ export const SchedulerJobDialog: FC = ({ {job ? ( ) : ( )} @@ -185,12 +185,12 @@ export const SchedulerJobDialog: FC = ({

@@ -205,7 +205,7 @@ export const SchedulerJobDialog: FC = ({ {/* Job Name */}
= ({ onChange={(e) => setName(e.target.value)} placeholder={_({ id: "scheduler.form.name.placeholder", - message: "例: 日次レポート", + message: "e.g., Daily Report", })} disabled={isSubmitting} /> @@ -224,7 +224,7 @@ export const SchedulerJobDialog: FC = ({ = ({

@@ -287,7 +287,7 @@ export const SchedulerJobDialog: FC = ({ {/* Message Content */}