mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-25 17:24:21 +01:00
implement frontend
This commit is contained in:
@@ -8,3 +8,5 @@ export {
|
||||
useContinueSessionProcessMutation,
|
||||
useCreateSessionProcessMutation,
|
||||
} from "./useChatMutations";
|
||||
export type { UseMessageCompletionResult } from "./useMessageCompletion";
|
||||
export { useMessageCompletion } from "./useMessageCompletion";
|
||||
|
||||
@@ -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 = " ";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
351
src/components/scheduler/CronExpressionBuilder.tsx
Normal file
351
src/components/scheduler/CronExpressionBuilder.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
421
src/components/scheduler/SchedulerJobDialog.tsx
Normal file
421
src/components/scheduler/SchedulerJobDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
24
src/components/ui/label.tsx
Normal file
24
src/components/ui/label.tsx
Normal 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 };
|
||||
50
src/components/ui/switch.tsx
Normal file
50
src/components/ui/switch.tsx
Normal 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
183
src/hooks/useScheduler.ts
Normal 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 };
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user