mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2026-01-09 00:24:22 +01:00
imporove loading
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user