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

@@ -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"

View File

@@ -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>

View 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;
};

View File

@@ -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,