imporove loading

This commit is contained in:
d-kimsuon
2025-10-26 15:29:32 +09:00
parent efa63a1224
commit aa7616a5c7
12 changed files with 395 additions and 335 deletions

View File

@@ -1,97 +1,15 @@
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
GitCompareIcon,
LoaderIcon,
MenuIcon,
PauseIcon,
XIcon,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
import { Button } from "@/components/ui/button";
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
import { DiffModal } from "./diffModal";
import { ContinueChat } from "./resumeChat/ContinueChat";
import { ResumeChat } from "./resumeChat/ResumeChat";
import { Suspense, useState } from "react";
import { Loading } from "@/components/Loading";
import { SessionPageMain } from "./SessionPageMain";
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
export const SessionPageContent: FC<{
projectId: string;
sessionId: string;
}> = ({ projectId, sessionId }) => {
const { session, conversations, getToolResult } = useSession(
projectId,
sessionId,
);
const { data: projectData } = useProject(projectId);
// biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page
const project = projectData.pages[0]!.project;
const abortTask = useMutation({
mutationFn: async (sessionProcessId: string) => {
const response = await honoClient.api.cc["session-processes"][
":sessionProcessId"
].abort.$post({
param: { sessionProcessId },
json: { projectId },
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
});
const sessionProcess = useSessionProcess();
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
const relatedSessionProcess = useMemo(
() => sessionProcess.getSessionProcess(sessionId),
[sessionProcess, sessionId],
);
// Set up task completion notifications
useTaskNotifications(relatedSessionProcess?.status === "running");
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理
useEffect(() => {
if (
relatedSessionProcess?.status === "running" &&
conversations.length !== previousConversationLength
) {
setPreviousConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
}
}
}, [
conversations,
relatedSessionProcess?.status,
previousConversationLength,
]);
return (
<div className="flex h-screen max-h-screen overflow-hidden">
@@ -101,178 +19,13 @@ export const SessionPageContent: FC<{
isMobileOpen={isMobileSidebarOpen}
onMobileOpenChange={setIsMobileSidebarOpen}
/>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="px-2 sm:px-3 py-2 sm:py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0 min-w-0">
<div className="space-y-2 sm:space-y-3">
<div className="flex items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="sm"
className="md:hidden flex-shrink-0"
onClick={() => setIsMobileSidebarOpen(true)}
data-testid="mobile-sidebar-toggle-button"
>
<MenuIcon className="w-4 h-4" />
</Button>
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
{session.meta.firstUserMessage !== null
? firstUserMessageToTitle(session.meta.firstUserMessage)
: sessionId}
</h1>
</div>
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.claudeProjectPath && (
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
)}
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{sessionId}
</Badge>
</div>
{relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium">
<Trans
id="session.conversation.in.progress"
message="Conversation is in progress..."
/>
</p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full animate-pulse"
style={{ width: "70%" }}
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
{relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
<Trans
id="session.conversation.paused"
message="Conversation is paused..."
/>
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
</div>
</header>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0 min-w-0"
data-testid="scrollable-content"
>
<main className="w-full px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 relative z-5 min-w-0">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
{relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<LoaderIcon className="w-8 h-8 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
</div>
<p className="text-sm text-muted-foreground font-medium animate-pulse">
<Trans
id="session.processing"
message="Claude Code is processing..."
/>
</p>
</div>
</div>
)}
{relatedSessionProcess !== undefined ? (
<ContinueChat
projectId={projectId}
sessionId={sessionId}
sessionProcessId={relatedSessionProcess.id}
/>
) : (
<ResumeChat projectId={projectId} sessionId={sessionId} />
)}
</main>
</div>
</div>
{/* Fixed Diff Button */}
<Button
onClick={() => setIsDiffModalOpen(true)}
className="fixed bottom-15 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
size="lg"
>
<GitCompareIcon className="w-6 h-6" />
</Button>
{/* Diff Modal */}
<DiffModal
projectId={projectId}
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
/>
{/* Permission Dialog */}
<PermissionDialog
permissionRequest={currentPermissionRequest}
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
<Suspense fallback={<Loading />}>
<SessionPageMain
projectId={projectId}
sessionId={sessionId}
setIsMobileSidebarOpen={setIsMobileSidebarOpen}
/>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,269 @@
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
GitCompareIcon,
LoaderIcon,
MenuIcon,
PauseIcon,
XIcon,
} from "lucide-react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
import { Button } from "@/components/ui/button";
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
import { DiffModal } from "./diffModal";
import { ContinueChat } from "./resumeChat/ContinueChat";
import { ResumeChat } from "./resumeChat/ResumeChat";
export const SessionPageMain: FC<{
projectId: string;
sessionId: string;
setIsMobileSidebarOpen: (open: boolean) => void;
}> = ({ projectId, sessionId, setIsMobileSidebarOpen }) => {
const { session, conversations, getToolResult } = useSession(
projectId,
sessionId,
);
const { data: projectData } = useProject(projectId);
// biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page
const project = projectData.pages[0]!.project;
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const abortTask = useMutation({
mutationFn: async (sessionProcessId: string) => {
const response = await honoClient.api.cc["session-processes"][
":sessionProcessId"
].abort.$post({
param: { sessionProcessId },
json: { projectId },
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
});
const sessionProcess = useSessionProcess();
const relatedSessionProcess = useMemo(
() => sessionProcess.getSessionProcess(sessionId),
[sessionProcess, sessionId],
);
// Set up task completion notifications
useTaskNotifications(relatedSessionProcess?.status === "running");
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理
useEffect(() => {
if (
relatedSessionProcess?.status === "running" &&
conversations.length !== previousConversationLength
) {
setPreviousConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
}
}
}, [
conversations,
relatedSessionProcess?.status,
previousConversationLength,
]);
return (
<>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="px-2 sm:px-3 py-2 sm:py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0 min-w-0">
<div className="space-y-2 sm:space-y-3">
<div className="flex items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="sm"
className="md:hidden flex-shrink-0"
onClick={() => setIsMobileSidebarOpen(true)}
data-testid="mobile-sidebar-toggle-button"
>
<MenuIcon className="w-4 h-4" />
</Button>
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
{session.meta.firstUserMessage !== null
? firstUserMessageToTitle(session.meta.firstUserMessage)
: sessionId}
</h1>
</div>
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.claudeProjectPath && (
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
)}
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{sessionId}
</Badge>
</div>
{relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium">
<Trans
id="session.conversation.in.progress"
message="Conversation is in progress..."
/>
</p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full animate-pulse"
style={{ width: "70%" }}
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
{relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
<Trans
id="session.conversation.paused"
message="Conversation is paused..."
/>
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
</div>
</header>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0 min-w-0"
data-testid="scrollable-content"
>
<main className="w-full px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 relative z-5 min-w-0">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
{relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<LoaderIcon className="w-8 h-8 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
</div>
<p className="text-sm text-muted-foreground font-medium animate-pulse">
<Trans
id="session.processing"
message="Claude Code is processing..."
/>
</p>
</div>
</div>
)}
{relatedSessionProcess !== undefined ? (
<ContinueChat
projectId={projectId}
sessionId={sessionId}
sessionProcessId={relatedSessionProcess.id}
/>
) : (
<ResumeChat projectId={projectId} sessionId={sessionId} />
)}
</main>
</div>
</div>
{/* Fixed Diff Button */}
<Button
onClick={() => setIsDiffModalOpen(true)}
className="fixed bottom-15 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
size="lg"
>
<GitCompareIcon className="w-6 h-6" />
</Button>
{/* Diff Modal */}
<DiffModal
projectId={projectId}
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
/>
{/* Permission Dialog */}
<PermissionDialog
permissionRequest={currentPermissionRequest}
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
</>
);
};

View File

@@ -0,0 +1,349 @@
import { Trans } from "@lingui/react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
type CronMode = "hourly" | "daily" | "weekly" | "custom";
interface CronExpressionBuilderProps {
value: string;
onChange: (expression: string) => void;
}
interface ParsedCron {
mode: CronMode;
hour: number;
minute: number;
dayOfWeek: number;
}
const WEEKDAYS = [
{
value: 0,
labelKey: <Trans id="cron_builder.sunday" message="Sunday" />,
},
{
value: 1,
labelKey: <Trans id="cron_builder.monday" message="Monday" />,
},
{
value: 2,
labelKey: <Trans id="cron_builder.tuesday" message="Tuesday" />,
},
{
value: 3,
labelKey: <Trans id="cron_builder.wednesday" message="Wednesday" />,
},
{
value: 4,
labelKey: <Trans id="cron_builder.thursday" message="Thursday" />,
},
{
value: 5,
labelKey: <Trans id="cron_builder.friday" message="Friday" />,
},
{
value: 6,
labelKey: <Trans id="cron_builder.saturday" message="Saturday" />,
},
];
function parseCronExpression(expression: string): ParsedCron | null {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return null;
const minute = parts[0];
const hour = parts[1];
const dayOfWeek = parts[4];
if (!minute || !hour || !dayOfWeek) return null;
// Hourly: "0 * * * *"
if (hour === "*" && minute === "0") {
return { mode: "hourly", hour: 0, minute: 0, dayOfWeek: 0 };
}
// Daily: "0 9 * * *"
if (dayOfWeek === "*" && hour !== "*") {
const h = Number.parseInt(hour, 10);
const m = Number.parseInt(minute, 10);
if (!Number.isNaN(h) && !Number.isNaN(m)) {
return { mode: "daily", hour: h, minute: m, dayOfWeek: 0 };
}
}
// Weekly: "0 9 * * 1"
if (dayOfWeek !== "*" && hour !== "*") {
const h = Number.parseInt(hour, 10);
const m = Number.parseInt(minute, 10);
const dow = Number.parseInt(dayOfWeek, 10);
if (!Number.isNaN(h) && !Number.isNaN(m) && !Number.isNaN(dow)) {
return { mode: "weekly", hour: h, minute: m, dayOfWeek: dow };
}
}
return { mode: "custom", hour: 0, minute: 0, dayOfWeek: 0 };
}
function buildCronExpression(
mode: CronMode,
hour: number,
minute: number,
dayOfWeek: number,
): string {
switch (mode) {
case "hourly":
return "0 * * * *";
case "daily":
return `${minute} ${hour} * * *`;
case "weekly":
return `${minute} ${hour} * * ${dayOfWeek}`;
case "custom":
return "0 0 * * *";
}
}
function validateCronExpression(expression: string): boolean {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return false;
const minute = parts[0];
const hour = parts[1];
const dayOfMonth = parts[2];
const month = parts[3];
const dayOfWeek = parts[4];
if (!minute || !hour || !dayOfMonth || !month || !dayOfWeek) return false;
const isValidField = (field: string, min: number, max: number): boolean => {
if (field === "*") return true;
const num = Number.parseInt(field, 10);
return !Number.isNaN(num) && num >= min && num <= max;
};
return (
isValidField(minute, 0, 59) &&
(hour === "*" || isValidField(hour, 0, 23)) &&
(dayOfMonth === "*" || isValidField(dayOfMonth, 1, 31)) &&
(month === "*" || isValidField(month, 1, 12)) &&
(dayOfWeek === "*" || isValidField(dayOfWeek, 0, 6))
);
}
function getNextExecutionPreview(expression: string): React.ReactNode {
const parts = expression.trim().split(/\s+/);
if (parts.length !== 5) return "Invalid cron expression";
const minute = parts[0];
const hour = parts[1];
const dayOfWeek = parts[4];
if (!minute || !hour || !dayOfWeek) return "Invalid cron expression";
if (hour === "*") {
return `Every hour at ${minute} minute(s)`;
}
const timeStr = `${hour.padStart(2, "0")}:${minute.padStart(2, "0")}`;
if (dayOfWeek === "*") {
return `Every day at ${timeStr}`;
}
const dow = Number.parseInt(dayOfWeek, 10);
const dayName = WEEKDAYS.find((d) => d.value === dow);
return (
<>
Every {dayName ? dayName.labelKey : "unknown"} at {timeStr}
</>
);
}
export function CronExpressionBuilder({
value,
onChange,
}: CronExpressionBuilderProps) {
const parsed = parseCronExpression(value);
const [mode, setMode] = useState<CronMode>(parsed?.mode || "daily");
const [hour, setHour] = useState(parsed?.hour || 9);
const [minute, setMinute] = useState(parsed?.minute || 0);
const [dayOfWeek, setDayOfWeek] = useState(parsed?.dayOfWeek || 1);
const [customExpression, setCustomExpression] = useState(value);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (mode === "custom") {
if (validateCronExpression(customExpression)) {
onChange(customExpression);
setError(null);
} else {
setError("Invalid cron expression");
}
} else {
const expr = buildCronExpression(mode, hour, minute, dayOfWeek);
onChange(expr);
setCustomExpression(expr);
setError(null);
}
}, [mode, hour, minute, dayOfWeek, customExpression, onChange]);
const handleModeChange = (newMode: CronMode) => {
setMode(newMode);
if (newMode !== "custom") {
const expr = buildCronExpression(newMode, hour, minute, dayOfWeek);
setCustomExpression(expr);
}
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.schedule_type" message="Schedule Type" />
</Label>
<Select value={mode} onValueChange={handleModeChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="hourly">
<Trans id="cron_builder.hourly" message="Hourly" />
</SelectItem>
<SelectItem value="daily">
<Trans id="cron_builder.daily" message="Daily" />
</SelectItem>
<SelectItem value="weekly">
<Trans id="cron_builder.weekly" message="Weekly" />
</SelectItem>
<SelectItem value="custom">
<Trans id="cron_builder.custom" message="Custom" />
</SelectItem>
</SelectContent>
</Select>
</div>
{mode === "daily" && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.hour" message="Hour (0-23)" />
</Label>
<Input
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(Number.parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label>
<Trans id="cron_builder.minute" message="Minute (0-59)" />
</Label>
<Input
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(Number.parseInt(e.target.value, 10))}
/>
</div>
</div>
)}
{mode === "weekly" && (
<div className="space-y-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.day_of_week" message="Day of Week" />
</Label>
<Select
value={String(dayOfWeek)}
onValueChange={(v) => setDayOfWeek(Number.parseInt(v, 10))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{WEEKDAYS.map((day) => (
<SelectItem key={day.value} value={String(day.value)}>
{day.labelKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>
<Trans id="cron_builder.hour" message="Hour (0-23)" />
</Label>
<Input
type="number"
min="0"
max="23"
value={hour}
onChange={(e) => setHour(Number.parseInt(e.target.value, 10))}
/>
</div>
<div className="space-y-2">
<Label>
<Trans id="cron_builder.minute" message="Minute (0-59)" />
</Label>
<Input
type="number"
min="0"
max="59"
value={minute}
onChange={(e) => setMinute(Number.parseInt(e.target.value, 10))}
/>
</div>
</div>
</div>
)}
{mode === "custom" && (
<div className="space-y-2">
<Label>
<Trans
id="cron_builder.cron_expression"
message="Cron Expression"
/>
</Label>
<Input
value={customExpression}
onChange={(e) => setCustomExpression(e.target.value)}
placeholder="0 9 * * *"
/>
</div>
)}
<div className="rounded-md border p-3 text-sm">
<div className="font-medium mb-1">
<Trans id="cron_builder.preview" message="Preview" />
</div>
<div className="text-muted-foreground">
{error ? (
<span className="text-destructive">{error}</span>
) : (
getNextExecutionPreview(
mode === "custom" ? customExpression : value,
)
)}
</div>
<div className="text-xs text-muted-foreground mt-2">
<Trans id="cron_builder.expression" message="Expression" />:{" "}
<code>{mode === "custom" ? customExpression : value}</code>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,403 @@
import { Trans, useLingui } from "@lingui/react";
import { type FC, 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;
}
export const SchedulerJobDialog: FC<SchedulerJobDialogProps> = ({
open,
onOpenChange,
job,
projectId,
onSubmit,
isSubmitting = false,
}) => {
const { _, i18n } = useLingui();
const [name, setName] = useState("");
const [scheduleType, setScheduleType] = useState<"cron" | "reserved">("cron");
const [cronExpression, setCronExpression] = useState("0 9 * * *");
const [reservedDateTime, setReservedDateTime] = useState(() => {
const now = new Date();
now.setHours(now.getHours() + 1);
return now.toISOString().slice(0, 16);
});
const [messageContent, setMessageContent] = useState("");
const [enabled, setEnabled] = useState(true);
const [concurrencyPolicy, setConcurrencyPolicy] = useState<"skip" | "run">(
"skip",
);
// Message completion hook
const completion = useMessageCompletion();
// 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);
setConcurrencyPolicy(job.schedule.concurrencyPolicy);
} else if (job.schedule.type === "reserved") {
// Convert UTC time to local time for display
const date = new Date(job.schedule.reservedExecutionTime);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
setReservedDateTime(`${year}-${month}-${day}T${hours}:${minutes}`);
}
setMessageContent(job.message.content);
setEnabled(job.enabled);
} else {
// Reset form for new job
setName("");
setScheduleType("cron");
setCronExpression("0 9 * * *");
const now = new Date();
now.setHours(now.getHours() + 1);
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
setReservedDateTime(`${year}-${month}-${day}T${hours}:${minutes}`);
setMessageContent("");
setEnabled(true);
setConcurrencyPolicy("skip");
}
}, [job]);
const handleSubmit = () => {
const newJob: NewSchedulerJob = {
name,
schedule:
scheduleType === "cron"
? {
type: "cron",
expression: cronExpression,
concurrencyPolicy,
}
: {
type: "reserved",
// datetime-local returns "YYYY-MM-DDTHH:mm" in local time
// We need to treat this as local time and convert to UTC
reservedExecutionTime: (() => {
// datetime-local format: "YYYY-MM-DDTHH:mm"
// Parse as local time and convert to ISO string (UTC)
const match = reservedDateTime.match(
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/,
);
if (!match) {
throw new Error("Invalid datetime format");
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
const hours = Number(match[4]);
const minutes = Number(match[5]);
const localDate = new Date(
year,
month - 1,
day,
hours,
minutes,
);
return localDate.toISOString();
})(),
},
message: {
content: messageContent,
projectId,
baseSessionId: null,
},
enabled,
};
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" | "reserved") =>
setScheduleType(value)
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="cron">
<Trans
id="scheduler.form.schedule_type.cron"
message="定期実行 (Cron)"
/>
</SelectItem>
<SelectItem value="reserved">
<Trans
id="scheduler.form.schedule_type.reserved"
message="予約実行"
/>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Schedule Configuration */}
{scheduleType === "cron" ? (
<CronExpressionBuilder
value={cronExpression}
onChange={setCronExpression}
/>
) : (
<div className="space-y-2">
<Label htmlFor="reserved-datetime">
<Trans
id="scheduler.form.reserved_time"
message="実行予定日時"
/>
</Label>
<Input
id="reserved-datetime"
type="datetime-local"
value={reservedDateTime}
onChange={(e) => setReservedDateTime(e.target.value)}
disabled={isSubmitting}
/>
<p className="text-xs text-muted-foreground">
<Trans
id="scheduler.form.reserved_time.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 (only for cron schedules) */}
{scheduleType === "cron" && (
<div className="space-y-2">
<Label>
<Trans
id="scheduler.form.concurrency_policy"
message="同時実行ポリシー"
/>
</Label>
<Select
value={concurrencyPolicy}
onValueChange={(value: "skip" | "run") =>
setConcurrencyPolicy(value)
}
disabled={isSubmitting}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="skip">
<Trans
id="scheduler.form.concurrency_policy.skip"
message="実行中の場合はスキップ"
/>
</SelectItem>
<SelectItem value="run">
<Trans
id="scheduler.form.concurrency_policy.run"
message="実行中でも実行する"
/>
</SelectItem>
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isSubmitting}
>
<Trans id="common.cancel" message="キャンセル" />
</Button>
<Button
onClick={handleSubmit}
disabled={!isFormValid || isSubmitting}
>
{isSubmitting ? (
<Trans id="common.saving" message="保存中..." />
) : job ? (
<Trans id="common.update" message="更新" />
) : (
<Trans id="common.create" message="作成" />
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Loading } from "../../../../../../../components/Loading";
import { mcpListQuery } from "../../../../../../../lib/api/queries";
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
@@ -13,9 +14,14 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
data: mcpData,
isLoading,
error,
isFetching,
} = useQuery({
queryKey: mcpListQuery(projectId).queryKey,
queryFn: mcpListQuery(projectId).queryFn,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
});
const handleReload = () => {
@@ -36,11 +42,11 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
disabled={isLoading}
disabled={isLoading || isFetching}
title={i18n._("Reload MCP servers")}
>
<RefreshCwIcon
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
className={`w-3 h-3 ${isLoading || isFetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
@@ -50,7 +56,7 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
{isLoading && (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">
<Trans id="common.loading" message="Loading..." />
<Loading />
</div>
</div>
)}

View File

@@ -21,7 +21,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab";
import { SessionsTab } from "./SessionsTab";
@@ -39,13 +38,6 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
onClose,
}) => {
const { i18n } = useLingui();
const {
data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const [activeTab, setActiveTab] = useState<
"sessions" | "mcp" | "settings" | "system-info"
>("sessions");
@@ -93,15 +85,8 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
case "sessions":
return (
<SessionsTab
sessions={sessions.map((session) => ({
...session,
lastModifiedAt: new Date(session.lastModifiedAt),
}))}
currentSessionId={currentSessionId}
projectId={projectId}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
isMobile={true}
/>
);

View File

@@ -2,7 +2,6 @@ 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 {
@@ -21,13 +20,21 @@ import {
useSchedulerJobs,
useUpdateSchedulerJob,
} from "@/hooks/useScheduler";
import { Loading } from "../../../../../../../components/Loading";
import { SchedulerJobDialog } from "../scheduler/SchedulerJobDialog";
export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
projectId,
sessionId,
}) => {
const { i18n } = useLingui();
const { data: jobs, isLoading, error, refetch } = useSchedulerJobs();
const {
data: jobs,
isLoading,
isFetching,
error,
refetch,
} = useSchedulerJobs();
const createJob = useCreateSchedulerJob();
const updateJob = useUpdateSchedulerJob();
const deleteJob = useDeleteSchedulerJob();
@@ -164,11 +171,11 @@ export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
disabled={isLoading}
disabled={isLoading || isFetching}
title={i18n._({ id: "common.reload", message: "Reload" })}
>
<RefreshCwIcon
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
className={`w-3 h-3 ${isLoading || isFetching ? "animate-spin" : ""}`}
/>
</Button>
<Button
@@ -194,7 +201,7 @@ export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
{isLoading && (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">
<Trans id="common.loading" message="Loading..." />
<Loading />
</div>
</div>
)}

View File

@@ -6,7 +6,7 @@ import {
MessageSquareIcon,
PlugIcon,
} from "lucide-react";
import { type FC, useMemo } from "react";
import { type FC, Suspense, useMemo } from "react";
import type { SidebarTab } from "@/components/GlobalSidebar";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import {
@@ -16,7 +16,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { Loading } from "../../../../../../../components/Loading";
import { McpTab } from "./McpTab";
import { MobileSidebar } from "./MobileSidebar";
import { SchedulerTab } from "./SchedulerTab";
@@ -35,14 +35,6 @@ export const SessionSidebar: FC<{
isMobileOpen = false,
onMobileOpenChange,
}) => {
const {
data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const additionalTabs: SidebarTab[] = useMemo(
() => [
{
@@ -50,17 +42,12 @@ export const SessionSidebar: FC<{
icon: MessageSquareIcon,
title: "Show session list",
content: (
<SessionsTab
sessions={sessions.map((session) => ({
...session,
lastModifiedAt: new Date(session.lastModifiedAt),
}))}
currentSessionId={currentSessionId}
projectId={projectId}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
<Suspense fallback={<Loading />}>
<SessionsTab
currentSessionId={currentSessionId}
projectId={projectId}
/>
</Suspense>
),
},
{
@@ -78,14 +65,7 @@ export const SessionSidebar: FC<{
),
},
],
[
sessions,
currentSessionId,
projectId,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
],
[currentSessionId, projectId],
);
return (

View File

@@ -7,29 +7,25 @@ import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { formatLocaleDate } from "../../../../../../../lib/date/formatLocaleDate";
import type { Session } from "../../../../../../../server/core/types";
import { useConfig } from "../../../../../../hooks/useConfig";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { useProject } from "../../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
export const SessionsTab: FC<{
sessions: Session[];
currentSessionId: string;
projectId: string;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onLoadMore?: () => void;
isMobile?: boolean;
}> = ({
sessions,
currentSessionId,
projectId,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isMobile = false,
}) => {
}> = ({ currentSessionId, projectId, isMobile = false }) => {
const {
data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const sessionProcesses = useAtomValue(sessionProcessesAtom);
const { config } = useConfig();
@@ -61,8 +57,8 @@ export const SessionsTab: FC<{
}
// Then sort by lastModifiedAt (newest first)
const aTime = a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0;
const bTime = b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0;
const aTime = a.lastModifiedAt ? new Date(a.lastModifiedAt).getTime() : 0;
const bTime = b.lastModifiedAt ? new Date(b.lastModifiedAt).getTime() : 0;
return bTime - aTime;
});
@@ -164,10 +160,10 @@ export const SessionsTab: FC<{
})}
{/* Load More Button */}
{hasNextPage && onLoadMore && (
{hasNextPage && fetchNextPage && (
<div className="p-2">
<Button
onClick={onLoadMore}
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="sm"