mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-28 02:34:21 +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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Loading } from "./Loading";
|
||||
import { NotificationSettings } from "./NotificationSettings";
|
||||
import { SettingsControls } from "./SettingsControls";
|
||||
import { SystemInfoCard } from "./SystemInfoCard";
|
||||
@@ -182,7 +183,7 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
|
||||
{/* Content Area - Only shown when expanded */}
|
||||
{isExpanded && (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{activeTabContent}
|
||||
<Suspense fallback={<Loading />}>{activeTabContent}</Suspense>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
69
src/components/Loading.tsx
Normal file
69
src/components/Loading.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface LoadingProps {
|
||||
/**
|
||||
* ローディングメッセージ(省略可能)
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* サイズ(デフォルト: "default")
|
||||
*/
|
||||
size?: "sm" | "default" | "lg";
|
||||
/**
|
||||
* フルスクリーンで表示するか(デフォルト: true)
|
||||
*/
|
||||
fullScreen?: boolean;
|
||||
/**
|
||||
* 追加のクラス名
|
||||
*/
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: "w-4 h-4",
|
||||
default: "w-8 h-8",
|
||||
lg: "w-12 h-12",
|
||||
};
|
||||
|
||||
const textSizeMap = {
|
||||
sm: "text-sm",
|
||||
default: "text-base",
|
||||
lg: "text-lg",
|
||||
};
|
||||
|
||||
export const Loading: FC<LoadingProps> = ({
|
||||
message,
|
||||
size = "default",
|
||||
fullScreen = true,
|
||||
className,
|
||||
}) => {
|
||||
const content = (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-3",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Loader2 className={cn(sizeMap[size], "animate-spin text-primary")} />
|
||||
{message && (
|
||||
<p
|
||||
className={cn(textSizeMap[size], "text-muted-foreground font-medium")}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full min-h-[400px]">
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
};
|
||||
@@ -105,12 +105,6 @@ export const useUpdateSchedulerJob = () => {
|
||||
id: string;
|
||||
updates: UpdateSchedulerJob;
|
||||
}): Promise<SchedulerJob> => {
|
||||
// TODO: Hono RPC type inference for nested routes (.route()) with $patch is incomplete
|
||||
// This causes a TypeScript error even though the runtime behavior is correct
|
||||
// Possible solutions:
|
||||
// 1. Move scheduler routes directly to main route.ts instead of using .route()
|
||||
// 2. Wait for Hono RPC to improve type inference for nested routes
|
||||
// 3. Use type assertion (currently forbidden by CLAUDE.md)
|
||||
const response = await honoClient.api.scheduler.jobs[":id"].$patch({
|
||||
param: { id },
|
||||
json: updates,
|
||||
|
||||
Reference in New Issue
Block a user