implement frontend

This commit is contained in:
d-kimsuon
2025-10-25 14:40:44 +09:00
parent 974d87daf7
commit ef4521750f
17 changed files with 2156 additions and 185 deletions

View File

@@ -8,3 +8,5 @@ export {
useContinueSessionProcessMutation,
useCreateSessionProcessMutation,
} from "./useChatMutations";
export type { UseMessageCompletionResult } from "./useMessageCompletion";
export { useMessageCompletion } from "./useMessageCompletion";

View File

@@ -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<HTMLDivElement | null>;
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
commandCompletionRef: React.RefObject<CommandCompletionRef | null>;
fileCompletionRef: React.RefObject<FileCompletionRef | null>;
getCursorPosition: () =>
| {
relative: { top: number; left: number };
absolute: { top: number; left: number };
}
| undefined;
handleChange: (value: string, onChange: (value: string) => void) => void;
handleKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => 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<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const commandCompletionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(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 = "&nbsp;";
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<HTMLTextAreaElement>): 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,
};
}

View File

@@ -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<SchedulerJob | null>(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deletingJobId, setDeletingJobId] = useState<string | null>(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 (
<div className="flex flex-col h-full">
<div className="p-3 border-b border-sidebar-border">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-semibold text-sidebar-foreground">
<Trans id="scheduler.title" message="Scheduler" />
</h2>
<div className="flex gap-1">
<Button
onClick={() => refetch()}
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
disabled={isLoading}
title={i18n._({ id: "common.reload", message: "Reload" })}
>
<RefreshCwIcon
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
<Button
onClick={() => {
setEditingJob(null);
setDialogOpen(true);
}}
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
title={i18n._({
id: "scheduler.create_job",
message: "Create Job",
})}
>
<PlusIcon className="w-3 h-3" />
</Button>
</div>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
{isLoading && (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">
<Trans id="common.loading" message="Loading..." />
</div>
</div>
)}
{error && (
<div className="text-sm text-red-500">
<Trans
id="scheduler.error.load_failed"
message="Failed to load scheduler jobs: {error}"
values={{ error: error.message }}
/>
</div>
)}
{jobs && jobs.length === 0 && (
<div className="text-sm text-muted-foreground text-center py-8">
<Trans
id="scheduler.no_jobs"
message="No scheduled jobs. Click + to create one."
/>
</div>
)}
{jobs && jobs.length > 0 && (
<div className="space-y-3">
{jobs.map((job) => (
<div
key={job.id}
className="p-3 bg-sidebar-accent/50 rounded-md border border-sidebar-border"
>
<div className="flex items-start justify-between mb-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-sidebar-foreground truncate">
{job.name}
</h3>
<Badge
variant={job.enabled ? "default" : "secondary"}
className="text-xs"
>
{job.enabled ? (
<Trans
id="scheduler.status.enabled"
message="Enabled"
/>
) : (
<Trans
id="scheduler.status.disabled"
message="Disabled"
/>
)}
</Badge>
</div>
<p className="text-xs text-muted-foreground mt-1">
{formatSchedule(job)}
</p>
</div>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => handleEditClick(job)}
>
<EditIcon className="w-3 h-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive hover:text-destructive"
onClick={() => handleDeleteClick(job.id)}
>
<TrashIcon className="w-3 h-3" />
</Button>
</div>
</div>
{job.lastRunAt && (
<div className="text-xs text-muted-foreground mt-2 pt-2 border-t border-sidebar-border">
<div className="flex items-center justify-between">
<span>
<Trans id="scheduler.last_run" message="Last run: " />
<span>{formatLastRun(job.lastRunAt)}</span>
</span>
{job.lastRunStatus && (
<Badge
variant={
job.lastRunStatus === "success"
? "default"
: "destructive"
}
className="text-xs"
>
{job.lastRunStatus}
</Badge>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
{/* Create/Edit Dialog */}
<SchedulerJobDialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) setEditingJob(null);
}}
job={editingJob}
projectId={projectId}
currentSessionId={sessionId}
onSubmit={editingJob ? handleUpdateJob : handleCreateJob}
isSubmitting={createJob.isPending || updateJob.isPending}
/>
{/* Delete Confirmation Dialog */}
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
<Trans id="scheduler.delete_dialog.title" message="Delete Job" />
</DialogTitle>
<DialogDescription>
<Trans
id="scheduler.delete_dialog.description"
message="Are you sure you want to delete this job? This action cannot be undone."
/>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setDeleteDialogOpen(false);
setDeletingJobId(null);
}}
disabled={deleteJob.isPending}
>
<Trans id="common.cancel" message="Cancel" />
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteJob.isPending}
>
{deleteJob.isPending ? (
<Trans id="common.deleting" message="Deleting..." />
) : (
<Trans id="common.delete" message="Delete" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};

View File

@@ -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: <McpTab projectId={projectId} />,
},
{
id: "scheduler",
icon: CalendarClockIcon,
title: "Show scheduler jobs",
content: (
<SchedulerTab projectId={projectId} sessionId={currentSessionId} />
),
},
],
[
sessions,

View File

@@ -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: <Trans id="cron_builder.sunday" message="Sunday" />,
},
{
value: 1,
labelKey: <Trans id="cron_builder.monday" message="Monday" />,
},
{
value: 2,
labelKey: <Trans id="cron_builder.tuesday" message="Tuesday" />,
},
{
value: 3,
labelKey: <Trans id="cron_builder.wednesday" message="Wednesday" />,
},
{
value: 4,
labelKey: <Trans id="cron_builder.thursday" message="Thursday" />,
},
{
value: 5,
labelKey: <Trans id="cron_builder.friday" message="Friday" />,
},
{
value: 6,
labelKey: <Trans id="cron_builder.saturday" message="Saturday" />,
},
];
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<CronMode>(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<string | null>(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 (
<div className="space-y-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.schedule_type" message="Schedule Type" />
</Label>
<Select value={mode} onValueChange={handleModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">
<Trans id="cron_builder.hourly" message="Hourly" />
</SelectItem>
<SelectItem value="daily">
<Trans id="cron_builder.daily" message="Daily" />
</SelectItem>
<SelectItem value="weekly">
<Trans id="cron_builder.weekly" message="Weekly" />
</SelectItem>
<SelectItem value="custom">
<Trans id="cron_builder.custom" message="Custom" />
</SelectItem>
</SelectContent>
</Select>
</div>
{mode === "daily" && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.hour" message="Hour (0-23)" />
</Label>
<Input
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(Number.parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label>
<Trans id="cron_builder.minute" message="Minute (0-59)" />
</Label>
<Input
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(Number.parseInt(e.target.value, 10))}
/>
</div>
</div>
)}
{mode === "weekly" && (
<div className="space-y-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.day_of_week" message="Day of Week" />
</Label>
<Select
value={String(dayOfWeek)}
onValueChange={(v) => setDayOfWeek(Number.parseInt(v, 10))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEEKDAYS.map((day) => (
<SelectItem key={day.value} value={String(day.value)}>
{day.labelKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.hour" message="Hour (0-23)" />
</Label>
<Input
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(Number.parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label>
<Trans id="cron_builder.minute" message="Minute (0-59)" />
</Label>
<Input
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(Number.parseInt(e.target.value, 10))}
/>
</div>
</div>
</div>
)}
{mode === "custom" && (
<div className="space-y-2">
<Label>
<Trans
id="cron_builder.cron_expression"
message="Cron Expression"
/>
</Label>
<Input
value={customExpression}
onChange={(e) => setCustomExpression(e.target.value)}
placeholder="0 9 * * *"
/>
</div>
)}
<div className="rounded-md border p-3 text-sm">
<div className="font-medium mb-1">
<Trans id="cron_builder.preview" message="Preview" />
</div>
<div className="text-muted-foreground">
{error ? (
<span className="text-destructive">{error}</span>
) : (
getNextExecutionPreview(
mode === "custom" ? customExpression : value,
)
)}
</div>
<div className="text-xs text-muted-foreground mt-2">
<Trans id="cron_builder.expression" message="Expression" />:{" "}
<code>{mode === "custom" ? customExpression : value}</code>
</div>
</div>
</div>
);
}

View File

@@ -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<SchedulerJobDialogProps> = ({
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<DelayUnit>("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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{job ? (
<Trans
id="scheduler.dialog.title.edit"
message="スケジュールジョブを編集"
/>
) : (
<Trans
id="scheduler.dialog.title.create"
message="スケジュールジョブを作成"
/>
)}
</DialogTitle>
<DialogDescription>
<Trans
id="scheduler.dialog.description"
message="Claude Code にメッセージを送信するスケジュールジョブを設定します"
/>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Enabled Toggle */}
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-0.5">
<Label htmlFor="enabled" className="text-base font-semibold">
<Trans id="scheduler.form.enabled" message="有効化" />
</Label>
<p className="text-sm text-muted-foreground">
<Trans
id="scheduler.form.enabled.description"
message="このスケジュールジョブを有効または無効にします"
/>
</p>
</div>
<Switch
id="enabled"
checked={enabled}
onCheckedChange={setEnabled}
disabled={isSubmitting}
/>
</div>
{/* Job Name */}
<div className="space-y-2">
<Label htmlFor="job-name">
<Trans id="scheduler.form.name" message="ジョブ名" />
</Label>
<Input
id="job-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={_({
id: "scheduler.form.name.placeholder",
message: "例: 日次レポート",
})}
disabled={isSubmitting}
/>
</div>
{/* Schedule Type */}
<div className="space-y-2">
<Label>
<Trans
id="scheduler.form.schedule_type"
message="スケジュールタイプ"
/>
</Label>
<Select
value={scheduleType}
onValueChange={(value: "cron" | "fixed") =>
setScheduleType(value)
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cron">
<Trans
id="scheduler.form.schedule_type.cron"
message="定期実行 (Cron)"
/>
</SelectItem>
<SelectItem value="fixed">
<Trans
id="scheduler.form.schedule_type.fixed"
message="遅延実行"
/>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Schedule Configuration */}
{scheduleType === "cron" ? (
<CronExpressionBuilder
value={cronExpression}
onChange={setCronExpression}
/>
) : (
<div className="space-y-2">
<Label>
<Trans id="scheduler.form.delay" message="遅延時間" />
</Label>
<div className="flex gap-2">
<Input
type="number"
min="1"
value={delayValue}
onChange={(e) =>
setDelayValue(Number.parseInt(e.target.value, 10))
}
disabled={isSubmitting}
className="flex-1"
placeholder="60"
/>
<Select
value={delayUnit}
onValueChange={(value: DelayUnit) => setDelayUnit(value)}
disabled={isSubmitting}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="minutes">
<Trans
id="scheduler.form.delay_unit.minutes"
message="分"
/>
</SelectItem>
<SelectItem value="hours">
<Trans
id="scheduler.form.delay_unit.hours"
message="時間"
/>
</SelectItem>
<SelectItem value="days">
<Trans id="scheduler.form.delay_unit.days" message="日" />
</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">
<Trans
id="scheduler.form.delay.hint"
message="指定した遅延時間後に一度だけ実行されます"
/>
</p>
</div>
)}
{/* Message Content */}
<div className="space-y-2">
<Label htmlFor="message-content">
<Trans id="scheduler.form.message" message="メッセージ内容" />
</Label>
<div className="relative" ref={completion.containerRef}>
<Textarea
ref={completion.textareaRef}
id="message-content"
value={messageContent}
onChange={(e) =>
completion.handleChange(e.target.value, setMessageContent)
}
onKeyDown={(e) => completion.handleKeyDown(e)}
placeholder={i18n._({
id: "scheduler.form.message.placeholder",
message:
"Claude Code に送信するメッセージを入力... (/ でコマンド補完、@ でファイル補完)",
})}
rows={4}
disabled={isSubmitting}
className="resize-none"
aria-label={i18n._(
"Message input with completion support (/ for commands, @ for files)",
)}
aria-expanded={
messageContent.startsWith("/") || messageContent.includes("@")
}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<InlineCompletion
projectId={projectId}
message={messageContent}
commandCompletionRef={completion.commandCompletionRef}
fileCompletionRef={completion.fileCompletionRef}
handleCommandSelect={(cmd) =>
completion.handleCommandSelect(cmd, setMessageContent)
}
handleFileSelect={(file) =>
completion.handleFileSelect(file, setMessageContent)
}
cursorPosition={completion.cursorPosition}
/>
</div>
<p className="text-xs text-muted-foreground">
<Trans
id="scheduler.form.message.hint"
message="/ でコマンド補完、@ でファイル補完"
/>
</p>
</div>
{/* Concurrency Policy */}
<div className="space-y-2">
<Label>
<Trans
id="scheduler.form.concurrency_policy"
message="同時実行ポリシー"
/>
</Label>
<Select
value={concurrencyPolicy}
onValueChange={(value: "skip" | "run") =>
setConcurrencyPolicy(value)
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
<Trans
id="scheduler.form.concurrency_policy.skip"
message="実行中の場合はスキップ"
/>
</SelectItem>
<SelectItem value="run">
<Trans
id="scheduler.form.concurrency_policy.run"
message="実行中でも実行する"
/>
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
<Trans id="common.cancel" message="キャンセル" />
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || isSubmitting}
>
{isSubmitting ? (
<Trans id="common.saving" message="保存中..." />
) : job ? (
<Trans id="common.update" message="更新" />
) : (
<Trans id="common.create" message="作成" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,24 @@
import * as React from "react";
import { cn } from "@/lib/utils";
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, children, ...props }, ref) => (
// biome-ignore lint/a11y/noLabelWithoutControl: Label is used with htmlFor prop or wraps input elements
<label
ref={ref}
className={cn(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70",
className,
)}
{...props}
>
{children}
</label>
),
);
Label.displayName = "Label";
export { Label };

View File

@@ -0,0 +1,50 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
interface SwitchProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type"> {
checked?: boolean;
onCheckedChange?: (checked: boolean) => void;
}
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
({ className, checked, onCheckedChange, disabled, ...props }, ref) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
props.onChange?.(e);
};
return (
<label
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors",
"focus-within:outline-none focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
disabled && "cursor-not-allowed opacity-50",
checked ? "bg-primary" : "bg-input",
className,
)}
>
<input
type="checkbox"
className="sr-only"
ref={ref}
checked={checked}
onChange={handleChange}
disabled={disabled}
{...props}
/>
<span
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)}
/>
</label>
);
},
);
Switch.displayName = "Switch";
export { Switch };

183
src/hooks/useScheduler.ts Normal file
View File

@@ -0,0 +1,183 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
NewSchedulerJob,
SchedulerJob,
UpdateSchedulerJob,
} from "@/server/core/scheduler/schema";
import { honoClient } from "../lib/api/client";
/**
* Query key factory for scheduler-related queries
*/
const schedulerKeys = {
all: ["scheduler"] as const,
jobs: () => [...schedulerKeys.all, "jobs"] as const,
job: (id: string) => [...schedulerKeys.all, "job", id] as const,
};
/**
* Hook to fetch all scheduler jobs
*
* @example
* const { data: jobs, isLoading, error } = useSchedulerJobs();
*
* @returns Query result containing array of SchedulerJob
*/
export const useSchedulerJobs = () => {
return useQuery({
queryKey: schedulerKeys.jobs(),
queryFn: async (): Promise<SchedulerJob[]> => {
const response = await honoClient.api.scheduler.jobs.$get();
if (!response.ok) {
throw new Error("Failed to fetch scheduler jobs");
}
return response.json();
},
});
};
/**
* Hook to create a new scheduler job
*
* @example
* const createJob = useCreateSchedulerJob();
*
* createJob.mutate({
* name: "Daily Report",
* schedule: { type: "cron", expression: "0 9 * * *" },
* message: {
* content: "Generate daily report",
* projectId: "project-123",
* baseSessionId: null,
* },
* enabled: true,
* concurrencyPolicy: "skip",
* });
*
* @returns Mutation result for creating a scheduler job
*/
export const useCreateSchedulerJob = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (newJob: NewSchedulerJob): Promise<SchedulerJob> => {
const response = await honoClient.api.scheduler.jobs.$post({
json: newJob,
});
if (!response.ok) {
throw new Error("Failed to create scheduler job");
}
return response.json();
},
onSuccess: () => {
// Invalidate jobs list to refetch
void queryClient.invalidateQueries({ queryKey: schedulerKeys.jobs() });
},
});
};
/**
* Hook to update an existing scheduler job
*
* @example
* const updateJob = useUpdateSchedulerJob();
*
* updateJob.mutate({
* id: "job-123",
* updates: {
* enabled: false,
* name: "Updated Job Name",
* },
* });
*
* @returns Mutation result for updating a scheduler job
*/
export const useUpdateSchedulerJob = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
id,
updates,
}: {
id: string;
updates: UpdateSchedulerJob;
}): Promise<SchedulerJob> => {
// TODO: Hono RPC type inference for nested routes (.route()) with $patch is incomplete
// This causes a TypeScript error even though the runtime behavior is correct
// Possible solutions:
// 1. Move scheduler routes directly to main route.ts instead of using .route()
// 2. Wait for Hono RPC to improve type inference for nested routes
// 3. Use type assertion (currently forbidden by CLAUDE.md)
const response = await honoClient.api.scheduler.jobs[":id"].$patch({
param: { id },
json: updates,
});
if (!response.ok) {
if (response.status === 404) {
throw new Error("Job not found");
}
throw new Error("Failed to update scheduler job");
}
return response.json();
},
onSuccess: (data) => {
// Invalidate specific job and jobs list
void queryClient.invalidateQueries({
queryKey: schedulerKeys.job(data.id),
});
void queryClient.invalidateQueries({ queryKey: schedulerKeys.jobs() });
},
});
};
/**
* Hook to delete a scheduler job
*
* @example
* const deleteJob = useDeleteSchedulerJob();
*
* deleteJob.mutate("job-123", {
* onSuccess: () => {
* console.log("Job deleted successfully");
* },
* });
*
* @returns Mutation result for deleting a scheduler job
*/
export const useDeleteSchedulerJob = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (id: string): Promise<{ success: true }> => {
const response = await honoClient.api.scheduler.jobs[":id"].$delete({
param: { id },
});
if (!response.ok) {
if (response.status === 404) {
throw new Error("Job not found");
}
throw new Error("Failed to delete scheduler job");
}
return response.json();
},
onSuccess: (_, deletedId) => {
// Invalidate specific job and jobs list
void queryClient.invalidateQueries({
queryKey: schedulerKeys.job(deletedId),
});
void queryClient.invalidateQueries({ queryKey: schedulerKeys.jobs() });
},
});
};
// Export types for external use
export type { SchedulerJob, NewSchedulerJob, UpdateSchedulerJob };

View File

@@ -249,3 +249,16 @@ export const claudeCodeFeaturesQuery = {
return await response.json();
},
} as const;
export const schedulerJobsQuery = {
queryKey: ["scheduler", "jobs"],
queryFn: async () => {
const response = await honoClient.api.scheduler.jobs.$get();
if (!response.ok) {
throw new Error(`Failed to fetch scheduler jobs: ${response.statusText}`);
}
return await response.json();
},
} as const;

View File

@@ -23,16 +23,16 @@
"origin": [["src/components/SettingsControls.tsx", 306]],
"translation": "Select theme"
},
"Reload MCP servers": {
"Close sidebar": {
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx",
42
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
179
]
],
"translation": "Reload MCP servers"
"translation": "Close sidebar"
},
"Type your message... (Start with / for commands, @ for files, Enter to send)": {
"placeholders": {},
@@ -79,16 +79,16 @@
],
"translation": "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)"
},
"Close sidebar": {
"Reload MCP servers": {
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
173
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx",
42
]
],
"translation": "Close sidebar"
"translation": "Reload MCP servers"
},
"Type your message here... (Start with / for commands, @ for files, Enter to send)": {
"placeholders": {},
@@ -136,6 +136,14 @@
],
"translation": "Available commands"
},
"Message input with completion support": {
"placeholders": {},
"comments": [],
"origin": [
["src/app/projects/[projectId]/components/chatForm/ChatInput.tsx", 210]
],
"translation": "Message input with completion support"
},
"Uncommitted changes": {
"placeholders": {},
"comments": [],
@@ -153,7 +161,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
303
302
]
],
"translation": "Failed to commit"
@@ -164,7 +172,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
321
320
]
],
"translation": "Failed to push"
@@ -175,7 +183,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
356
355
]
],
"translation": "Retry Push"
@@ -186,7 +194,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
369
368
]
],
"translation": "Failed to commit and push"
@@ -197,7 +205,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
388
387
]
],
"translation": "Compare from"
@@ -208,19 +216,11 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
394
393
]
],
"translation": "Compare to"
},
"Message input with completion support": {
"placeholders": {},
"comments": [],
"origin": [
["src/app/projects/[projectId]/components/chatForm/ChatInput.tsx", 210]
],
"translation": "Message input with completion support"
},
"assistant.tool.message_count": {
"message": "{count} messages",
"placeholders": {
@@ -288,7 +288,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
197
203
],
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx",
@@ -414,7 +414,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
567
566
]
],
"translation": "Commit"
@@ -426,7 +426,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
602
601
]
],
"translation": "Commit & Push"
@@ -438,7 +438,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
463
462
]
],
"translation": "Commit Changes"
@@ -450,7 +450,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
535
534
]
],
"translation": "Commit message"
@@ -462,7 +462,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
596
595
]
],
"translation": "Committing & Pushing..."
@@ -474,7 +474,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
561
560
]
],
"translation": "Committing..."
@@ -531,6 +531,13 @@
"origin": [["src/app/projects/components/CreateProjectDialog.tsx", 106]],
"translation": "Creating..."
},
"cron_builder.cron_expression": {
"translation": "Cron Expression",
"message": "Cron Expression",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 281]]
},
"directory_picker.current": {
"message": "Current:",
"placeholders": {},
@@ -538,6 +545,20 @@
"origin": [["src/app/projects/components/DirectoryPicker.tsx", 38]],
"translation": "Current:"
},
"cron_builder.custom": {
"translation": "Custom",
"message": "Custom",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 192]]
},
"cron_builder.daily": {
"translation": "Daily",
"message": "Daily",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 186]]
},
"settings.theme.dark": {
"message": "Dark",
"placeholders": {},
@@ -545,6 +566,13 @@
"origin": [["src/components/SettingsControls.tsx", 313]],
"translation": "Dark"
},
"cron_builder.day_of_week": {
"translation": "Day of Week",
"message": "Day of Week",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 231]]
},
"settings.permission.mode.default": {
"message": "Default (Ask permission)",
"placeholders": {},
@@ -559,7 +587,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
492
491
]
],
"translation": "Deselect All"
@@ -592,7 +620,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
618
617
]
],
"translation": "Enter a commit message"
@@ -644,6 +672,13 @@
"origin": [["src/components/SystemInfoCard.tsx", 146]],
"translation": "Executable"
},
"cron_builder.expression": {
"translation": "Expression",
"message": "Expression",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 303]]
},
"mcp.error.load_failed": {
"message": "Failed to load MCP servers: {error}",
"placeholders": {
@@ -705,6 +740,13 @@
],
"translation": "files changed"
},
"cron_builder.friday": {
"translation": "Friday",
"message": "Friday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 36]]
},
"settings.session.hide_no_user_message": {
"message": "Hide sessions without user messages",
"placeholders": {},
@@ -712,6 +754,23 @@
"origin": [["src/components/SettingsControls.tsx", 117]],
"translation": "Hide sessions without user messages"
},
"cron_builder.hour": {
"translation": "Hour (0-23)",
"message": "Hour (0-23)",
"placeholders": {},
"comments": [],
"origin": [
["src/components/scheduler/CronExpressionBuilder.tsx", 202],
["src/components/scheduler/CronExpressionBuilder.tsx", 252]
]
},
"cron_builder.hourly": {
"translation": "Hourly",
"message": "Hourly",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 183]]
},
"user.content.image": {
"message": "Image",
"placeholders": {},
@@ -731,7 +790,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
198
170
]
],
"translation": "Input Parameters"
@@ -776,7 +835,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
657
656
]
],
"translation": "Loading diff..."
@@ -795,7 +854,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
115
119
],
["src/components/GlobalSidebar.tsx", 67]
],
@@ -822,7 +881,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
413
412
],
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx",
@@ -873,6 +932,23 @@
"origin": [["src/app/projects/components/ProjectList.tsx", 71]],
"translation": "Messages:"
},
"cron_builder.minute": {
"translation": "Minute (0-59)",
"message": "Minute (0-59)",
"placeholders": {},
"comments": [],
"origin": [
["src/components/scheduler/CronExpressionBuilder.tsx", 214],
["src/components/scheduler/CronExpressionBuilder.tsx", 264]
]
},
"cron_builder.monday": {
"translation": "Monday",
"message": "Monday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 32]]
},
"sessions.new": {
"message": "New",
"placeholders": {},
@@ -946,7 +1022,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
136
140
]
],
"translation": "Notifications"
@@ -998,6 +1074,13 @@
"origin": [["src/lib/notifications.tsx", 107]],
"translation": "Pop"
},
"cron_builder.preview": {
"translation": "Preview",
"message": "Preview",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 293]]
},
"chat.status.processing": {
"message": "Processing...",
"placeholders": {},
@@ -1021,7 +1104,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
582
581
]
],
"translation": "Push"
@@ -1033,7 +1116,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
579
578
]
],
"translation": "Pushing..."
@@ -1086,6 +1169,20 @@
],
"translation": "Running"
},
"cron_builder.saturday": {
"translation": "Saturday",
"message": "Saturday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 37]]
},
"cron_builder.schedule_type": {
"translation": "Schedule Type",
"message": "Schedule Type",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 175]]
},
"conversation.error.schema": {
"message": "Schema Error",
"placeholders": {},
@@ -1131,7 +1228,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
484
483
]
],
"translation": "Select All"
@@ -1143,7 +1240,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
613
612
]
],
"translation": "Select at least one file"
@@ -1188,7 +1285,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
126
130
]
],
"translation": "Session Display"
@@ -1219,7 +1316,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
278
284
],
["src/components/GlobalSidebar.tsx", 44]
],
@@ -1239,7 +1336,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
252
258
]
],
"translation": "Show MCP server settings"
@@ -1258,11 +1355,23 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
225
231
]
],
"translation": "Show session list"
},
"system.info.tab.title": {
"message": "Show system information",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
309
]
],
"translation": "Show system information"
},
"chat.button.start": {
"message": "Start Chat",
"placeholders": {},
@@ -1281,6 +1390,13 @@
],
"translation": "Start New Chat"
},
"cron_builder.sunday": {
"translation": "Sunday",
"message": "Sunday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 31]]
},
"settings.theme.system": {
"message": "System",
"placeholders": {},
@@ -1342,7 +1458,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
89
64
]
],
"translation": "Thinking"
@@ -1359,17 +1475,24 @@
],
"translation": "This conversation entry failed to parse correctly. This might indicate a format change or parsing issue."
},
"cron_builder.thursday": {
"translation": "Thursday",
"message": "Thursday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 35]]
},
"assistant.tool.tool_id": {
"translation": "Tool ID",
"message": "Tool ID",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
190
162
]
]
],
"translation": "Tool ID"
},
"assistant.tool.result": {
"message": "Tool Result",
@@ -1378,7 +1501,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
215
187
]
],
"translation": "Tool Result"
@@ -1409,6 +1532,13 @@
"origin": [["src/app/projects/[projectId]/error.tsx", 68]],
"translation": "Try Again"
},
"cron_builder.tuesday": {
"translation": "Tuesday",
"message": "Tuesday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 33]]
},
"settings.session.unify_same_title": {
"message": "Unify sessions with same title",
"placeholders": {},
@@ -1500,7 +1630,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
145
120
]
],
"translation": "View Task"
@@ -1512,6 +1642,20 @@
"origin": [["src/app/projects/[projectId]/error.tsx", 40]],
"translation": "We encountered an error while loading this project"
},
"cron_builder.wednesday": {
"translation": "Wednesday",
"message": "Wednesday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 34]]
},
"cron_builder.weekly": {
"translation": "Weekly",
"message": "Weekly",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 189]]
},
"projects.page.title": {
"message": "Your Projects",
"placeholders": {},

File diff suppressed because one or more lines are too long

View File

@@ -23,16 +23,16 @@
"origin": [["src/components/SettingsControls.tsx", 306]],
"translation": "テーマを選択"
},
"Reload MCP servers": {
"Close sidebar": {
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx",
42
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
179
]
],
"translation": "MCPサーバーを再読み込み"
"translation": "サイドバーを閉じる"
},
"Type your message... (Start with / for commands, @ for files, Enter to send)": {
"placeholders": {},
@@ -79,16 +79,16 @@
],
"translation": "メッセージを入力... /でコマンド、@でファイル、Shift+Enterで送信"
},
"Close sidebar": {
"Reload MCP servers": {
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
173
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx",
42
]
],
"translation": "サイドバーを閉じる"
"translation": "MCPサーバーを再読み込み"
},
"Type your message here... (Start with / for commands, @ for files, Enter to send)": {
"placeholders": {},
@@ -136,6 +136,14 @@
],
"translation": "利用可能なコマンド"
},
"Message input with completion support": {
"placeholders": {},
"comments": [],
"origin": [
["src/app/projects/[projectId]/components/chatForm/ChatInput.tsx", 210]
],
"translation": "補完機能付きメッセージ入力"
},
"Uncommitted changes": {
"placeholders": {},
"comments": [],
@@ -153,7 +161,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
303
302
]
],
"translation": "コミットに失敗しました"
@@ -164,7 +172,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
321
320
]
],
"translation": "プッシュに失敗しました"
@@ -175,7 +183,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
356
355
]
],
"translation": "プッシュを再試行"
@@ -186,7 +194,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
369
368
]
],
"translation": "コミットとプッシュに失敗しました"
@@ -197,7 +205,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
388
387
]
],
"translation": "比較元"
@@ -208,19 +216,11 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
394
393
]
],
"translation": "比較先"
},
"Message input with completion support": {
"placeholders": {},
"comments": [],
"origin": [
["src/app/projects/[projectId]/components/chatForm/ChatInput.tsx", 210]
],
"translation": "補完機能付きメッセージ入力"
},
"assistant.tool.message_count": {
"message": "{count} messages",
"placeholders": {
@@ -288,7 +288,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
197
203
],
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx",
@@ -414,7 +414,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
567
566
]
],
"translation": "コミット"
@@ -426,7 +426,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
602
601
]
],
"translation": "コミット&プッシュ"
@@ -438,7 +438,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
463
462
]
],
"translation": "変更をコミット"
@@ -450,7 +450,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
535
534
]
],
"translation": "コミットメッセージ"
@@ -462,7 +462,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
596
595
]
],
"translation": "コミット&プッシュ中..."
@@ -474,7 +474,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
561
560
]
],
"translation": "コミット中..."
@@ -531,6 +531,13 @@
"origin": [["src/app/projects/components/CreateProjectDialog.tsx", 106]],
"translation": "作成中..."
},
"cron_builder.cron_expression": {
"translation": "Cron式",
"message": "Cron Expression",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 281]]
},
"directory_picker.current": {
"message": "Current:",
"placeholders": {},
@@ -538,6 +545,20 @@
"origin": [["src/app/projects/components/DirectoryPicker.tsx", 38]],
"translation": "現在:"
},
"cron_builder.custom": {
"translation": "カスタム",
"message": "Custom",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 192]]
},
"cron_builder.daily": {
"translation": "毎日",
"message": "Daily",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 186]]
},
"settings.theme.dark": {
"message": "Dark",
"placeholders": {},
@@ -545,6 +566,13 @@
"origin": [["src/components/SettingsControls.tsx", 313]],
"translation": "ダーク"
},
"cron_builder.day_of_week": {
"translation": "曜日",
"message": "Day of Week",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 231]]
},
"settings.permission.mode.default": {
"message": "Default (Ask permission)",
"placeholders": {},
@@ -559,7 +587,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
492
491
]
],
"translation": "すべて選択解除"
@@ -592,7 +620,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
618
617
]
],
"translation": "コミットメッセージを入力"
@@ -644,6 +672,13 @@
"origin": [["src/components/SystemInfoCard.tsx", 146]],
"translation": "実行ファイル"
},
"cron_builder.expression": {
"translation": "Cron式",
"message": "Expression",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 303]]
},
"mcp.error.load_failed": {
"message": "Failed to load MCP servers: {error}",
"placeholders": {
@@ -705,6 +740,13 @@
],
"translation": "ファイルが変更されました"
},
"cron_builder.friday": {
"translation": "金曜日",
"message": "Friday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 36]]
},
"settings.session.hide_no_user_message": {
"message": "Hide sessions without user messages",
"placeholders": {},
@@ -712,6 +754,23 @@
"origin": [["src/components/SettingsControls.tsx", 117]],
"translation": "ユーザーメッセージのないセッションを非表示"
},
"cron_builder.hour": {
"translation": "時 (0-23)",
"message": "Hour (0-23)",
"placeholders": {},
"comments": [],
"origin": [
["src/components/scheduler/CronExpressionBuilder.tsx", 202],
["src/components/scheduler/CronExpressionBuilder.tsx", 252]
]
},
"cron_builder.hourly": {
"translation": "毎時",
"message": "Hourly",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 183]]
},
"user.content.image": {
"message": "Image",
"placeholders": {},
@@ -731,7 +790,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
198
170
]
],
"translation": "入力パラメータ"
@@ -776,7 +835,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
657
656
]
],
"translation": "差分を読み込み中..."
@@ -795,7 +854,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
115
119
],
["src/components/GlobalSidebar.tsx", 67]
],
@@ -822,7 +881,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
413
412
],
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx",
@@ -873,6 +932,23 @@
"origin": [["src/app/projects/components/ProjectList.tsx", 71]],
"translation": "メッセージ:"
},
"cron_builder.minute": {
"translation": "分 (0-59)",
"message": "Minute (0-59)",
"placeholders": {},
"comments": [],
"origin": [
["src/components/scheduler/CronExpressionBuilder.tsx", 214],
["src/components/scheduler/CronExpressionBuilder.tsx", 264]
]
},
"cron_builder.monday": {
"translation": "月曜日",
"message": "Monday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 32]]
},
"sessions.new": {
"message": "New",
"placeholders": {},
@@ -946,7 +1022,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
136
140
]
],
"translation": "通知"
@@ -998,6 +1074,13 @@
"origin": [["src/lib/notifications.tsx", 107]],
"translation": "ポップ"
},
"cron_builder.preview": {
"translation": "プレビュー",
"message": "Preview",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 293]]
},
"chat.status.processing": {
"message": "Processing...",
"placeholders": {},
@@ -1021,7 +1104,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
582
581
]
],
"translation": "プッシュ"
@@ -1033,7 +1116,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
579
578
]
],
"translation": "プッシュ中..."
@@ -1086,6 +1169,20 @@
],
"translation": "実行中"
},
"cron_builder.saturday": {
"translation": "土曜日",
"message": "Saturday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 37]]
},
"cron_builder.schedule_type": {
"translation": "スケジュールタイプ",
"message": "Schedule Type",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 175]]
},
"conversation.error.schema": {
"message": "Schema Error",
"placeholders": {},
@@ -1131,7 +1228,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
484
483
]
],
"translation": "すべて選択"
@@ -1143,7 +1240,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/diffModal/DiffModal.tsx",
613
612
]
],
"translation": "少なくとも1つのファイルを選択してください"
@@ -1188,7 +1285,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
126
130
]
],
"translation": "セッション表示"
@@ -1219,7 +1316,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
278
284
],
["src/components/GlobalSidebar.tsx", 44]
],
@@ -1239,7 +1336,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
252
258
]
],
"translation": "MCPサーバー設定を表示"
@@ -1258,11 +1355,23 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
225
231
]
],
"translation": "セッション一覧を表示"
},
"system.info.tab.title": {
"message": "Show system information",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx",
309
]
],
"translation": "システム情報を表示"
},
"chat.button.start": {
"message": "Start Chat",
"placeholders": {},
@@ -1281,6 +1390,13 @@
],
"translation": "新しいチャットを開始"
},
"cron_builder.sunday": {
"translation": "日曜日",
"message": "Sunday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 31]]
},
"settings.theme.system": {
"message": "System",
"placeholders": {},
@@ -1342,7 +1458,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
89
64
]
],
"translation": "思考中"
@@ -1359,17 +1475,24 @@
],
"translation": "この会話エントリの解析に失敗しました。フォーマットの変更または解析の問題が考えられます。"
},
"cron_builder.thursday": {
"translation": "木曜日",
"message": "Thursday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 35]]
},
"assistant.tool.tool_id": {
"translation": "ツールID",
"message": "Tool ID",
"placeholders": {},
"comments": [],
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
190
162
]
]
],
"translation": "ツールID"
},
"assistant.tool.result": {
"message": "Tool Result",
@@ -1378,7 +1501,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
215
187
]
],
"translation": "ツール実行結果"
@@ -1409,6 +1532,13 @@
"origin": [["src/app/projects/[projectId]/error.tsx", 68]],
"translation": "再試行"
},
"cron_builder.tuesday": {
"translation": "火曜日",
"message": "Tuesday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 33]]
},
"settings.session.unify_same_title": {
"message": "Unify sessions with same title",
"placeholders": {},
@@ -1500,7 +1630,7 @@
"origin": [
[
"src/app/projects/[projectId]/sessions/[sessionId]/components/conversationList/AssistantConversationContent.tsx",
145
120
]
],
"translation": "タスクを確認"
@@ -1512,6 +1642,20 @@
"origin": [["src/app/projects/[projectId]/error.tsx", 40]],
"translation": "このプロジェクトの読み込み中にエラーが発生しました"
},
"cron_builder.wednesday": {
"translation": "水曜日",
"message": "Wednesday",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 34]]
},
"cron_builder.weekly": {
"translation": "毎週",
"message": "Weekly",
"placeholders": {},
"comments": [],
"origin": [["src/components/scheduler/CronExpressionBuilder.tsx", 189]]
},
"projects.page.title": {
"message": "Your Projects",
"placeholders": {},

File diff suppressed because one or more lines are too long

View File

@@ -18,7 +18,7 @@ import type {
SchedulerJob,
UpdateSchedulerJob,
} from "../schema";
import { calculateFixedDelay, executeJob, shouldExecuteJob } from "./Job";
import { calculateFixedDelay, executeJob } from "./Job";
class SchedulerJobNotFoundError extends Data.TaggedError(
"SchedulerJobNotFoundError",
@@ -55,18 +55,28 @@ const LayerImpl = Effect.gen(function* () {
);
}
const schedule = Schedule.cron(cronResult.right);
const cronSchedule = Schedule.cron(cronResult.right);
const fiber = yield* Effect.repeat(
runJobWithConcurrencyControl(job),
schedule,
).pipe(Effect.forkDaemon);
// Wait for the next cron time before starting the repeat loop
// This prevents immediate execution on job creation/update
const fiber = yield* Effect.gen(function* () {
// Get the next scheduled time
const nextTime = Cron.next(cronResult.right, new Date());
const nextDelay = Math.max(0, nextTime.getTime() - Date.now());
// Wait until the next scheduled time
yield* Effect.sleep(Duration.millis(nextDelay));
// Then repeat on the cron schedule
yield* Effect.repeat(runJobWithConcurrencyControl(job), cronSchedule);
}).pipe(Effect.forkDaemon);
yield* Ref.update(fibersRef, (fibers) =>
new Map(fibers).set(job.id, fiber),
);
} else if (job.schedule.type === "fixed") {
if (!shouldExecuteJob(job, now)) {
// For oneTime jobs, skip scheduling if already executed
if (job.schedule.oneTime && job.lastRunStatus !== null) {
return;
}

View File

@@ -1,99 +1,84 @@
import type { FileSystem, Path } from "@effect/platform";
import type { CommandExecutor } from "@effect/platform/CommandExecutor";
import { Context, Effect, Layer, Runtime } from "effect";
import { Hono, type Context as HonoContext } from "hono";
import { Context, Effect, Layer } from "effect";
import type { ControllerResponse } from "../../../lib/effect/toEffectResponse";
import type { InferEffect } from "../../../lib/effect/types";
import type { ClaudeCodeLifeCycleService } from "../../claude-code/services/ClaudeCodeLifeCycleService";
import type { EnvService } from "../../platform/services/EnvService";
import type { UserConfigService } from "../../platform/services/UserConfigService";
import type { ProjectRepository } from "../../project/infrastructure/ProjectRepository";
import { SchedulerService } from "../domain/Scheduler";
import { newSchedulerJobSchema, updateSchedulerJobSchema } from "../schema";
import type { NewSchedulerJob, UpdateSchedulerJob } from "../schema";
const LayerImpl = Effect.gen(function* () {
const schedulerService = yield* SchedulerService;
const runtime = yield* Effect.runtime<
| FileSystem.FileSystem
| Path.Path
| CommandExecutor
| EnvService
| ProjectRepository
| UserConfigService
| ClaudeCodeLifeCycleService
>();
const getJobs = () =>
Effect.gen(function* () {
const jobs = yield* schedulerService.getJobs();
return {
response: jobs,
status: 200,
} as const satisfies ControllerResponse;
});
const app = new Hono()
.get("/jobs", async (c: HonoContext) => {
const result = await Runtime.runPromise(runtime)(
schedulerService.getJobs(),
);
return c.json(result);
})
.post("/jobs", async (c: HonoContext) => {
const body = await c.req.json();
const parsed = newSchedulerJobSchema.safeParse(body);
const addJob = (options: { job: NewSchedulerJob }) =>
Effect.gen(function* () {
const { job } = options;
const result = yield* schedulerService.addJob(job);
return {
response: result,
status: 201,
} as const satisfies ControllerResponse;
});
if (!parsed.success) {
return c.json(
{ error: "Invalid request body", details: parsed.error },
400,
);
}
const result = await Runtime.runPromise(runtime)(
schedulerService.addJob(parsed.data),
);
return c.json(result, 201);
})
.patch("/jobs/:id", async (c: HonoContext) => {
const id = c.req.param("id");
const body = await c.req.json();
const parsed = updateSchedulerJobSchema.safeParse(body);
if (!parsed.success) {
return c.json(
{ error: "Invalid request body", details: parsed.error },
400,
);
}
const result = await Runtime.runPromise(runtime)(
schedulerService
.updateJob(id, parsed.data)
.pipe(
Effect.catchTag("SchedulerJobNotFoundError", () =>
Effect.succeed(null),
),
const updateJob = (options: { id: string; job: UpdateSchedulerJob }) =>
Effect.gen(function* () {
const { id, job } = options;
const result = yield* schedulerService
.updateJob(id, job)
.pipe(
Effect.catchTag("SchedulerJobNotFoundError", () =>
Effect.succeed(null),
),
);
);
if (result === null) {
return c.json({ error: "Job not found" }, 404);
return {
response: { error: "Job not found" },
status: 404,
} as const satisfies ControllerResponse;
}
return c.json(result);
})
.delete("/jobs/:id", async (c: HonoContext) => {
const id = c.req.param("id");
return {
response: result,
status: 200,
} as const satisfies ControllerResponse;
});
const result = await Runtime.runPromise(runtime)(
schedulerService.deleteJob(id).pipe(
Effect.catchTag("SchedulerJobNotFoundError", () =>
Effect.succeed(false),
),
Effect.map(() => true),
const deleteJob = (options: { id: string }) =>
Effect.gen(function* () {
const { id } = options;
const result = yield* schedulerService.deleteJob(id).pipe(
Effect.catchTag("SchedulerJobNotFoundError", () =>
Effect.succeed(false),
),
Effect.map(() => true),
);
if (!result) {
return c.json({ error: "Job not found" }, 404);
return {
response: { error: "Job not found" },
status: 404,
} as const satisfies ControllerResponse;
}
return c.json({ success: true }, 200);
return {
response: { success: true },
status: 200,
} as const satisfies ControllerResponse;
});
return { app };
return {
getJobs,
addJob,
updateJob,
deleteJob,
};
});
export type ISchedulerController = InferEffect<typeof LayerImpl>;

View File

@@ -17,6 +17,7 @@ import { GitController } from "../core/git/presentation/GitController";
import { CommitRequestSchema, PushRequestSchema } from "../core/git/schema";
import { EnvService } from "../core/platform/services/EnvService";
import { UserConfigService } from "../core/platform/services/UserConfigService";
import type { ProjectRepository } from "../core/project/infrastructure/ProjectRepository";
import { ProjectController } from "../core/project/presentation/ProjectController";
import { SchedulerController } from "../core/scheduler/presentation/SchedulerController";
import type { VirtualConversationDatabase } from "../core/session/infrastructure/VirtualConversationDatabase";
@@ -56,6 +57,9 @@ export const routes = (app: HonoAppType) =>
| FileSystem.FileSystem
| Path.Path
| CommandExecutor.CommandExecutor
| UserConfigService
| ClaudeCodeLifeCycleService
| ProjectRepository
>();
if ((yield* envService.getEnv("NEXT_PHASE")) !== "phase-production-build") {
@@ -446,7 +450,108 @@ export const routes = (app: HonoAppType) =>
* SchedulerController Routes
*/
.route("/scheduler", schedulerController.app)
.get("/scheduler/jobs", async (c) => {
const response = await effectToResponse(
c,
schedulerController.getJobs().pipe(Effect.provide(runtime)),
);
return response;
})
.post(
"/scheduler/jobs",
zValidator(
"json",
z.object({
name: z.string(),
schedule: z.discriminatedUnion("type", [
z.object({
type: z.literal("cron"),
expression: z.string(),
}),
z.object({
type: z.literal("fixed"),
delayMs: z.number().int().positive(),
oneTime: z.boolean(),
}),
]),
message: z.object({
content: z.string(),
projectId: z.string(),
baseSessionId: z.string().nullable(),
}),
enabled: z.boolean().default(true),
concurrencyPolicy: z.enum(["skip", "run"]).default("skip"),
}),
),
async (c) => {
const response = await effectToResponse(
c,
schedulerController
.addJob({
job: c.req.valid("json"),
})
.pipe(Effect.provide(runtime)),
);
return response;
},
)
.patch(
"/scheduler/jobs/:id",
zValidator(
"json",
z.object({
name: z.string().optional(),
schedule: z
.discriminatedUnion("type", [
z.object({
type: z.literal("cron"),
expression: z.string(),
}),
z.object({
type: z.literal("fixed"),
delayMs: z.number().int().positive(),
oneTime: z.boolean(),
}),
])
.optional(),
message: z
.object({
content: z.string(),
projectId: z.string(),
baseSessionId: z.string().nullable(),
})
.optional(),
enabled: z.boolean().optional(),
concurrencyPolicy: z.enum(["skip", "run"]).optional(),
}),
),
async (c) => {
const response = await effectToResponse(
c,
schedulerController
.updateJob({
id: c.req.param("id"),
job: c.req.valid("json"),
})
.pipe(Effect.provide(runtime)),
);
return response;
},
)
.delete("/scheduler/jobs/:id", async (c) => {
const response = await effectToResponse(
c,
schedulerController
.deleteJob({
id: c.req.param("id"),
})
.pipe(Effect.provide(runtime)),
);
return response;
})
/**
* FileSystemController Routes