mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-20 14:54:19 +01:00
perf: refactor sse handleing
This commit is contained in:
33
src/app/components/SSEEventListeners.tsx
Normal file
33
src/app/components/SSEEventListeners.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useSetAtom } from "jotai";
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
import { projectDetailQuery, sessionDetailQuery } from "../../lib/api/queries";
|
||||||
|
import { useServerEventListener } from "../../lib/sse/hook/useServerEventListener";
|
||||||
|
import { aliveTasksAtom } from "../projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
|
||||||
|
|
||||||
|
export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const setAliveTasks = useSetAtom(aliveTasksAtom);
|
||||||
|
|
||||||
|
useServerEventListener("sessionListChanged", async (event) => {
|
||||||
|
// invalidate session list
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: projectDetailQuery(event.projectId).queryKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useServerEventListener("sessionChanged", async (event) => {
|
||||||
|
// invalidate session detail
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: sessionDetailQuery(event.projectId, event.sessionId).queryKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
useServerEventListener("taskChanged", async (event) => {
|
||||||
|
setAliveTasks(event.aliveTasks);
|
||||||
|
});
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useServerEvents } from "@/hooks/useServerEvents";
|
|
||||||
|
|
||||||
interface ServerEventsProviderProps {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ServerEventsProvider({ children }: ServerEventsProviderProps) {
|
|
||||||
useServerEvents();
|
|
||||||
|
|
||||||
return <>{children}</>;
|
|
||||||
}
|
|
||||||
@@ -5,21 +5,15 @@ import {
|
|||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { honoClient } from "../../lib/api/client";
|
import { honoClient } from "../../lib/api/client";
|
||||||
|
import { configQuery } from "../../lib/api/queries";
|
||||||
import type { Config } from "../../server/config/config";
|
import type { Config } from "../../server/config/config";
|
||||||
|
|
||||||
export const configQueryConfig = {
|
|
||||||
queryKey: ["config"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await honoClient.api.config.$get();
|
|
||||||
return await response.json();
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const useConfig = () => {
|
export const useConfig = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data } = useSuspenseQuery({
|
const { data } = useSuspenseQuery({
|
||||||
...configQueryConfig,
|
queryKey: configQuery.queryKey,
|
||||||
|
queryFn: configQuery.queryFn,
|
||||||
});
|
});
|
||||||
const updateConfigMutation = useMutation({
|
const updateConfigMutation = useMutation({
|
||||||
mutationFn: async (config: Config) => {
|
mutationFn: async (config: Config) => {
|
||||||
@@ -30,7 +24,7 @@ export const useConfig = () => {
|
|||||||
},
|
},
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: configQueryConfig.queryKey,
|
queryKey: configQuery.queryKey,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
|
||||||
import { Toaster } from "../components/ui/sonner";
|
import { Toaster } from "../components/ui/sonner";
|
||||||
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
|
||||||
|
import { SSEProvider } from "../lib/sse/components/SSEProvider";
|
||||||
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
import { RootErrorBoundary } from "./components/RootErrorBoundary";
|
||||||
import { ServerEventsProvider } from "./components/ServerEventsProvider";
|
|
||||||
|
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { QueryClient } from "@tanstack/react-query";
|
import { configQuery } from "../lib/api/queries";
|
||||||
import { configQueryConfig } from "./hooks/useConfig";
|
import { SSEEventListeners } from "./components/SSEEventListeners";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const fetchCache = "force-no-store";
|
export const fetchCache = "force-no-store";
|
||||||
@@ -35,7 +37,8 @@ export default async function RootLayout({
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
...configQueryConfig,
|
queryKey: configQuery.queryKey,
|
||||||
|
queryFn: configQuery.queryFn,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -45,7 +48,9 @@ export default async function RootLayout({
|
|||||||
>
|
>
|
||||||
<RootErrorBoundary>
|
<RootErrorBoundary>
|
||||||
<QueryClientProviderWrapper>
|
<QueryClientProviderWrapper>
|
||||||
<ServerEventsProvider>{children}</ServerEventsProvider>
|
<SSEProvider>
|
||||||
|
<SSEEventListeners>{children}</SSEEventListeners>
|
||||||
|
</SSEProvider>
|
||||||
</QueryClientProviderWrapper>
|
</QueryClientProviderWrapper>
|
||||||
</RootErrorBoundary>
|
</RootErrorBoundary>
|
||||||
<Toaster position="top-right" />
|
<Toaster position="top-right" />
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ import {
|
|||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
CollapsibleTrigger,
|
CollapsibleTrigger,
|
||||||
} from "@/components/ui/collapsible";
|
} from "@/components/ui/collapsible";
|
||||||
|
import { projectDetailQuery } from "../../../../lib/api/queries";
|
||||||
import { useConfig } from "../../../hooks/useConfig";
|
import { useConfig } from "../../../hooks/useConfig";
|
||||||
import { projectQueryConfig, useProject } from "../hooks/useProject";
|
import { useProject } from "../hooks/useProject";
|
||||||
import { firstCommandToTitle } from "../services/firstCommandToTitle";
|
import { firstCommandToTitle } from "../services/firstCommandToTitle";
|
||||||
import { NewChatModal } from "./newChat/NewChatModal";
|
import { NewChatModal } from "./newChat/NewChatModal";
|
||||||
|
|
||||||
@@ -41,7 +42,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
|||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
|
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: projectQueryConfig(projectId).queryKey,
|
queryKey: projectDetailQuery(projectId).queryKey,
|
||||||
});
|
});
|
||||||
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
|
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
Collapsible,
|
Collapsible,
|
||||||
CollapsibleContent,
|
CollapsibleContent,
|
||||||
} from "../../../../../components/ui/collapsible";
|
} from "../../../../../components/ui/collapsible";
|
||||||
import { honoClient } from "../../../../../lib/api/client";
|
import { claudeCommandsQuery } from "../../../../../lib/api/queries";
|
||||||
import { cn } from "../../../../../lib/utils";
|
import { cn } from "../../../../../lib/utils";
|
||||||
|
|
||||||
type CommandCompletionProps = {
|
type CommandCompletionProps = {
|
||||||
@@ -40,18 +40,8 @@ export const CommandCompletion = forwardRef<
|
|||||||
|
|
||||||
// コマンドリストを取得
|
// コマンドリストを取得
|
||||||
const { data: commandData } = useQuery({
|
const { data: commandData } = useQuery({
|
||||||
queryKey: ["claude-commands", projectId],
|
queryKey: claudeCommandsQuery(projectId).queryKey,
|
||||||
queryFn: async () => {
|
queryFn: claudeCommandsQuery(projectId).queryFn,
|
||||||
const response = await honoClient.api.projects[":projectId"][
|
|
||||||
"claude-commands"
|
|
||||||
].$get({
|
|
||||||
param: { projectId },
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch commands");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
|
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,10 @@
|
|||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { honoClient } from "../../../../lib/api/client";
|
import { projectDetailQuery } from "../../../../lib/api/queries";
|
||||||
|
|
||||||
export const projectQueryConfig = (projectId: string) =>
|
|
||||||
({
|
|
||||||
queryKey: ["projects", projectId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await honoClient.api.projects[":projectId"].$get({
|
|
||||||
param: { projectId },
|
|
||||||
});
|
|
||||||
|
|
||||||
return await response.json();
|
|
||||||
},
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
export const useProject = (projectId: string) => {
|
export const useProject = (projectId: string) => {
|
||||||
return useSuspenseQuery({
|
return useSuspenseQuery({
|
||||||
...projectQueryConfig(projectId),
|
queryKey: projectDetailQuery(projectId).queryKey,
|
||||||
|
queryFn: projectDetailQuery(projectId).queryFn,
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {
|
|||||||
HydrationBoundary,
|
HydrationBoundary,
|
||||||
QueryClient,
|
QueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
|
import { projectDetailQuery } from "../../../lib/api/queries";
|
||||||
import { ProjectPageContent } from "./components/ProjectPage";
|
import { ProjectPageContent } from "./components/ProjectPage";
|
||||||
import { projectQueryConfig } from "./hooks/useProject";
|
|
||||||
|
|
||||||
interface ProjectPageProps {
|
interface ProjectPageProps {
|
||||||
params: Promise<{ projectId: string }>;
|
params: Promise<{ projectId: string }>;
|
||||||
@@ -16,7 +16,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
...projectQueryConfig(projectId),
|
queryKey: projectDetailQuery(projectId).queryKey,
|
||||||
|
queryFn: projectDetailQuery(projectId).queryFn,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { RefreshCwIcon } from "lucide-react";
|
import { RefreshCwIcon } from "lucide-react";
|
||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { honoClient } from "@/lib/api/client";
|
import { mcpListQuery } from "../../../../../../../lib/api/queries";
|
||||||
|
|
||||||
export const McpTab: FC = () => {
|
export const McpTab: FC = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -14,18 +14,12 @@ export const McpTab: FC = () => {
|
|||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["mcp", "list"],
|
queryKey: mcpListQuery.queryKey,
|
||||||
queryFn: async () => {
|
queryFn: mcpListQuery.queryFn,
|
||||||
const response = await honoClient.api.mcp.list.$get();
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch MCP servers");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleReload = () => {
|
const handleReload = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["mcp", "list"] });
|
queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,24 +1,18 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { honoClient } from "../../../../../../lib/api/client";
|
import { aliveTasksQuery } from "../../../../../../lib/api/queries";
|
||||||
import { aliveTasksAtom } from "../store/aliveTasksAtom";
|
import { aliveTasksAtom } from "../store/aliveTasksAtom";
|
||||||
|
|
||||||
export const useAliveTask = (sessionId: string) => {
|
export const useAliveTask = (sessionId: string) => {
|
||||||
const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
|
const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
|
||||||
|
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: ["aliveTasks"],
|
queryKey: aliveTasksQuery.queryKey,
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await honoClient.api.tasks.alive.$get({});
|
const { aliveTasks } = await aliveTasksQuery.queryFn();
|
||||||
|
setAliveTasks(aliveTasks);
|
||||||
if (!response.ok) {
|
return aliveTasks;
|
||||||
throw new Error(response.statusText);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
setAliveTasks(data.aliveTasks);
|
|
||||||
return response.json();
|
|
||||||
},
|
},
|
||||||
refetchOnReconnect: true,
|
refetchOnReconnect: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,42 +1,22 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { honoClient } from "@/lib/api/client";
|
import { honoClient } from "@/lib/api/client";
|
||||||
|
import {
|
||||||
|
gitBranchesQuery,
|
||||||
|
gitCommitsQuery,
|
||||||
|
} from "../../../../../../lib/api/queries";
|
||||||
|
|
||||||
export const useGitBranches = (projectId: string) => {
|
export const useGitBranches = (projectId: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["git", "branches", projectId],
|
queryKey: gitBranchesQuery(projectId).queryKey,
|
||||||
queryFn: async () => {
|
queryFn: gitBranchesQuery(projectId).queryFn,
|
||||||
const response = await honoClient.api.projects[
|
|
||||||
":projectId"
|
|
||||||
].git.branches.$get({
|
|
||||||
param: { projectId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch branches: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
staleTime: 30000, // 30 seconds
|
staleTime: 30000, // 30 seconds
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useGitCommits = (projectId: string) => {
|
export const useGitCommits = (projectId: string) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["git", "commits", projectId],
|
queryKey: gitCommitsQuery(projectId).queryKey,
|
||||||
queryFn: async () => {
|
queryFn: gitCommitsQuery(projectId).queryFn,
|
||||||
const response = await honoClient.api.projects[
|
|
||||||
":projectId"
|
|
||||||
].git.commits.$get({
|
|
||||||
param: { projectId },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to fetch commits: ${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
staleTime: 30000, // 30 seconds
|
staleTime: 30000, // 30 seconds
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,25 +1,9 @@
|
|||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { honoClient } from "../../../../../../lib/api/client";
|
import { sessionDetailQuery } from "../../../../../../lib/api/queries";
|
||||||
|
|
||||||
export const sessionQueryConfig = (projectId: string, sessionId: string) =>
|
|
||||||
({
|
|
||||||
queryKey: ["sessions", sessionId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await honoClient.api.projects[":projectId"].sessions[
|
|
||||||
":sessionId"
|
|
||||||
].$get({
|
|
||||||
param: {
|
|
||||||
projectId,
|
|
||||||
sessionId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
}) as const;
|
|
||||||
|
|
||||||
export const useSessionQuery = (projectId: string, sessionId: string) => {
|
export const useSessionQuery = (projectId: string, sessionId: string) => {
|
||||||
return useSuspenseQuery({
|
return useSuspenseQuery({
|
||||||
...sessionQueryConfig(projectId, sessionId),
|
queryKey: sessionDetailQuery(projectId, sessionId).queryKey,
|
||||||
|
queryFn: sessionDetailQuery(projectId, sessionId).queryFn,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { projectQueryConfig } from "../../hooks/useProject";
|
import {
|
||||||
|
projectDetailQuery,
|
||||||
|
sessionDetailQuery,
|
||||||
|
} from "../../../../../lib/api/queries";
|
||||||
import { SessionPageContent } from "./components/SessionPageContent";
|
import { SessionPageContent } from "./components/SessionPageContent";
|
||||||
import { sessionQueryConfig } from "./hooks/useSessionQuery";
|
|
||||||
|
|
||||||
type PageParams = {
|
type PageParams = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -19,11 +21,12 @@ export async function generateMetadata({
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
...sessionQueryConfig(projectId, sessionId),
|
...sessionDetailQuery(projectId, sessionId),
|
||||||
});
|
});
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
...projectQueryConfig(projectId),
|
queryKey: projectDetailQuery(projectId).queryKey,
|
||||||
|
queryFn: projectDetailQuery(projectId).queryFn,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import {
|
|||||||
import { useProjects } from "../hooks/useProjects";
|
import { useProjects } from "../hooks/useProjects";
|
||||||
|
|
||||||
export const ProjectList: FC = () => {
|
export const ProjectList: FC = () => {
|
||||||
const { data: projects } = useProjects();
|
const {
|
||||||
|
data: { projects },
|
||||||
|
} = useProjects();
|
||||||
|
|
||||||
if (projects.length === 0) {
|
if (projects.length === 0) {
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { honoClient } from "../../../lib/api/client";
|
import { projectListQuery } from "../../../lib/api/queries";
|
||||||
|
|
||||||
export const projetsQueryConfig = {
|
|
||||||
queryKey: ["projects"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const response = await honoClient.api.projects.$get();
|
|
||||||
const { projects } = await response.json();
|
|
||||||
return projects;
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export const useProjects = () => {
|
export const useProjects = () => {
|
||||||
return useSuspenseQuery({
|
return useSuspenseQuery({
|
||||||
queryKey: projetsQueryConfig.queryKey,
|
queryKey: projectListQuery.queryKey,
|
||||||
queryFn: projetsQueryConfig.queryFn,
|
queryFn: projectListQuery.queryFn,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { QueryClient } from "@tanstack/react-query";
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
import { HistoryIcon } from "lucide-react";
|
import { HistoryIcon } from "lucide-react";
|
||||||
|
import { projectListQuery } from "../../lib/api/queries";
|
||||||
import { ProjectList } from "./components/ProjectList";
|
import { ProjectList } from "./components/ProjectList";
|
||||||
import { projetsQueryConfig } from "./hooks/useProjects";
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
export const fetchCache = "force-no-store";
|
export const fetchCache = "force-no-store";
|
||||||
@@ -10,8 +10,8 @@ export default async function ProjectsPage() {
|
|||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
await queryClient.prefetchQuery({
|
await queryClient.prefetchQuery({
|
||||||
queryKey: projetsQueryConfig.queryKey,
|
queryKey: projectListQuery.queryKey,
|
||||||
queryFn: projetsQueryConfig.queryFn,
|
queryFn: projectListQuery.queryFn,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { type FC, useCallback, useId } from "react";
|
import { type FC, useCallback, useId } from "react";
|
||||||
import { configQueryConfig, useConfig } from "@/app/hooks/useConfig";
|
import { useConfig } from "@/app/hooks/useConfig";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { projectQueryConfig } from "../app/projects/[projectId]/hooks/useProject";
|
import {
|
||||||
|
configQuery,
|
||||||
|
projectDetailQuery,
|
||||||
|
projectListQuery,
|
||||||
|
} from "../lib/api/queries";
|
||||||
|
|
||||||
interface SettingsControlsProps {
|
interface SettingsControlsProps {
|
||||||
openingProjectId: string;
|
openingProjectId: string;
|
||||||
@@ -33,13 +37,13 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
|
|||||||
|
|
||||||
const onConfigChanged = useCallback(async () => {
|
const onConfigChanged = useCallback(async () => {
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: configQueryConfig.queryKey,
|
queryKey: configQuery.queryKey,
|
||||||
});
|
});
|
||||||
await queryClient.invalidateQueries({
|
await queryClient.invalidateQueries({
|
||||||
queryKey: ["projects"],
|
queryKey: projectListQuery.queryKey,
|
||||||
});
|
});
|
||||||
void queryClient.invalidateQueries({
|
void queryClient.invalidateQueries({
|
||||||
queryKey: projectQueryConfig(openingProjectId).queryKey,
|
queryKey: projectDetailQuery(openingProjectId).queryKey,
|
||||||
});
|
});
|
||||||
}, [queryClient, openingProjectId]);
|
}, [queryClient, openingProjectId]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { honoClient } from "../lib/api/client";
|
import { fileCompletionQuery } from "../lib/api/queries";
|
||||||
|
|
||||||
export type FileCompletionEntry = {
|
export type FileCompletionEntry = {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -19,21 +19,8 @@ export const useFileCompletion = (
|
|||||||
enabled = true,
|
enabled = true,
|
||||||
) => {
|
) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["file-completion", projectId, basePath],
|
queryKey: fileCompletionQuery(projectId, basePath).queryKey,
|
||||||
queryFn: async (): Promise<FileCompletionResult> => {
|
queryFn: fileCompletionQuery(projectId, basePath).queryFn,
|
||||||
const response = await honoClient.api.projects[":projectId"][
|
|
||||||
"file-completion"
|
|
||||||
].$get({
|
|
||||||
param: { projectId },
|
|
||||||
query: { basePath },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch file completion");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
enabled: enabled && !!projectId,
|
enabled: enabled && !!projectId,
|
||||||
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
|
staleTime: 1000 * 60 * 5, // 5分間キャッシュ
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,113 +0,0 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useSetAtom } from "jotai";
|
|
||||||
import { useCallback, useEffect } from "react";
|
|
||||||
import { aliveTasksAtom } from "../app/projects/[projectId]/sessions/[sessionId]/store/aliveTasksAtom";
|
|
||||||
import { projetsQueryConfig } from "../app/projects/hooks/useProjects";
|
|
||||||
import { honoClient } from "../lib/api/client";
|
|
||||||
import type { SSEEvent } from "../server/service/events/types";
|
|
||||||
|
|
||||||
type ParsedEvent = {
|
|
||||||
event: string;
|
|
||||||
data: SSEEvent;
|
|
||||||
id: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseSSEEvent = (text: string): ParsedEvent => {
|
|
||||||
const lines = text.split("\n");
|
|
||||||
const eventIndex = lines.findIndex((line) => line.startsWith("event:"));
|
|
||||||
const dataIndex = lines.findIndex((line) => line.startsWith("data:"));
|
|
||||||
const idIndex = lines.findIndex((line) => line.startsWith("id:"));
|
|
||||||
|
|
||||||
const endIndex = (index: number) => {
|
|
||||||
const targets = [eventIndex, dataIndex, idIndex, lines.length].filter(
|
|
||||||
(current) => current > index,
|
|
||||||
);
|
|
||||||
return Math.min(...targets);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (eventIndex === -1 || dataIndex === -1 || idIndex === -1) {
|
|
||||||
console.error("failed", text);
|
|
||||||
throw new Error("Failed to parse SSE event");
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = lines.slice(eventIndex, endIndex(eventIndex)).join("\n");
|
|
||||||
const data = lines.slice(dataIndex, endIndex(dataIndex)).join("\n");
|
|
||||||
const id = lines.slice(idIndex, endIndex(idIndex)).join("\n");
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: id.slice("id:".length).trim(),
|
|
||||||
event: event.slice("event:".length).trim(),
|
|
||||||
data: JSON.parse(
|
|
||||||
data.slice(data.indexOf("{"), data.lastIndexOf("}") + 1),
|
|
||||||
) as SSEEvent,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const parseSSEEvents = (text: string): ParsedEvent[] => {
|
|
||||||
const eventTexts = text
|
|
||||||
.split("\n\n")
|
|
||||||
.filter((eventText) => eventText.length > 0);
|
|
||||||
|
|
||||||
return eventTexts.map((eventText) => parseSSEEvent(eventText));
|
|
||||||
};
|
|
||||||
|
|
||||||
let isInitialized = false;
|
|
||||||
|
|
||||||
export const useServerEvents = () => {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const setAliveTasks = useSetAtom(aliveTasksAtom);
|
|
||||||
|
|
||||||
const listener = useCallback(async () => {
|
|
||||||
console.log("listening to events");
|
|
||||||
const response = await honoClient.api.events.state_changes.$get();
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to fetch events");
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body?.getReader();
|
|
||||||
if (!reader) {
|
|
||||||
throw new Error("Failed to get reader");
|
|
||||||
}
|
|
||||||
|
|
||||||
const decoder = new TextDecoder();
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read();
|
|
||||||
if (done) break;
|
|
||||||
const events = parseSSEEvents(decoder.decode(value));
|
|
||||||
|
|
||||||
for (const event of events) {
|
|
||||||
console.log("data", event);
|
|
||||||
|
|
||||||
if (event.data.type === "project_changed") {
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: projetsQueryConfig.queryKey,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.data.type === "session_changed") {
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ["sessions"] });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.data.type === "task_changed") {
|
|
||||||
setAliveTasks(event.data.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [queryClient, setAliveTasks]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isInitialized === false) {
|
|
||||||
void listener()
|
|
||||||
.then(() => {
|
|
||||||
console.log("registered events listener");
|
|
||||||
isInitialized = true;
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("failed to register events listener", error);
|
|
||||||
isInitialized = true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [listener]);
|
|
||||||
};
|
|
||||||
168
src/lib/api/queries.ts
Normal file
168
src/lib/api/queries.ts
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion";
|
||||||
|
import { honoClient } from "./client";
|
||||||
|
|
||||||
|
export const projectListQuery = {
|
||||||
|
queryKey: ["projects"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.projects.$get({
|
||||||
|
param: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch projects: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const projectDetailQuery = (projectId: string) =>
|
||||||
|
({
|
||||||
|
queryKey: ["projects", projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.projects[":projectId"].$get({
|
||||||
|
param: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch project: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export const sessionDetailQuery = (projectId: string, sessionId: string) =>
|
||||||
|
({
|
||||||
|
queryKey: ["projects", projectId, "sessions", sessionId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.projects[":projectId"].sessions[
|
||||||
|
":sessionId"
|
||||||
|
].$get({
|
||||||
|
param: {
|
||||||
|
projectId,
|
||||||
|
sessionId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch session: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export const claudeCommandsQuery = (projectId: string) =>
|
||||||
|
({
|
||||||
|
queryKey: ["claude-commands", projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.projects[":projectId"][
|
||||||
|
"claude-commands"
|
||||||
|
].$get({
|
||||||
|
param: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to fetch claude commands: ${response.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export const aliveTasksQuery = {
|
||||||
|
queryKey: ["aliveTasks"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.tasks.alive.$get({});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch alive tasks: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const gitBranchesQuery = (projectId: string) =>
|
||||||
|
({
|
||||||
|
queryKey: ["git", "branches", projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.projects[
|
||||||
|
":projectId"
|
||||||
|
].git.branches.$get({
|
||||||
|
param: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch branches: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export const gitCommitsQuery = (projectId: string) =>
|
||||||
|
({
|
||||||
|
queryKey: ["git", "commits", projectId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.projects[
|
||||||
|
":projectId"
|
||||||
|
].git.commits.$get({
|
||||||
|
param: { projectId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch commits: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export const mcpListQuery = {
|
||||||
|
queryKey: ["mcp", "list"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.mcp.list.$get();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch MCP list: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const fileCompletionQuery = (projectId: string, basePath: string) =>
|
||||||
|
({
|
||||||
|
queryKey: ["file-completion", projectId, basePath],
|
||||||
|
queryFn: async (): Promise<FileCompletionResult> => {
|
||||||
|
const response = await honoClient.api.projects[":projectId"][
|
||||||
|
"file-completion"
|
||||||
|
].$get({
|
||||||
|
param: { projectId },
|
||||||
|
query: { basePath },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error("Failed to fetch file completion");
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
}) as const;
|
||||||
|
|
||||||
|
export const configQuery = {
|
||||||
|
queryKey: ["config"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await honoClient.api.config.$get();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch config: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
25
src/lib/sse/SSEContext.ts
Normal file
25
src/lib/sse/SSEContext.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import type { SSEEvent } from "../../types/sse";
|
||||||
|
|
||||||
|
export type EventListener<T extends SSEEvent["kind"]> = (
|
||||||
|
event: Extract<SSEEvent, { kind: T }>,
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
export type SSEContextType = {
|
||||||
|
addEventListener: <T extends SSEEvent["kind"]>(
|
||||||
|
eventType: T,
|
||||||
|
listener: EventListener<T>,
|
||||||
|
) => () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SSEContext = createContext<SSEContextType | null>(null);
|
||||||
|
|
||||||
|
export const useSSEContext = () => {
|
||||||
|
const context = useContext(SSEContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useSSEContext must be used within SSEProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
48
src/lib/sse/callSSE.ts
Normal file
48
src/lib/sse/callSSE.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import type { SSEEventMap } from "../../types/sse";
|
||||||
|
|
||||||
|
export const callSSE = () => {
|
||||||
|
const eventSource = new EventSource(
|
||||||
|
new URL("/api/sse", window.location.origin).href,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOnOpen = (event: Event) => {
|
||||||
|
console.log("SSE connection opened", event);
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onopen = handleOnOpen;
|
||||||
|
|
||||||
|
const addEventListener = <EventName extends keyof SSEEventMap>(
|
||||||
|
eventName: EventName,
|
||||||
|
listener: (event: SSEEventMap[EventName]) => void,
|
||||||
|
) => {
|
||||||
|
const callbackFn = (event: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const sseEvent: SSEEventMap[EventName] = JSON.parse(event.data);
|
||||||
|
listener(sseEvent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse SSE event data:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eventSource.addEventListener(eventName, callbackFn);
|
||||||
|
|
||||||
|
const removeEventListener = () => {
|
||||||
|
eventSource.removeEventListener(eventName, callbackFn);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
removeEventListener,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanUp = () => {
|
||||||
|
eventSource.onopen = null;
|
||||||
|
eventSource.onmessage = null;
|
||||||
|
eventSource.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
addEventListener,
|
||||||
|
cleanUp,
|
||||||
|
eventSource,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
8
src/lib/sse/components/SSEProvider.tsx
Normal file
8
src/lib/sse/components/SSEProvider.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { FC, PropsWithChildren } from "react";
|
||||||
|
import { ServerEventsProvider } from "./ServerEventsProvider";
|
||||||
|
|
||||||
|
export const SSEProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
return <ServerEventsProvider>{children}</ServerEventsProvider>;
|
||||||
|
};
|
||||||
110
src/lib/sse/components/ServerEventsProvider.tsx
Normal file
110
src/lib/sse/components/ServerEventsProvider.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { useAtom } from "jotai";
|
||||||
|
import {
|
||||||
|
type FC,
|
||||||
|
type PropsWithChildren,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
} from "react";
|
||||||
|
import type { SSEEvent } from "../../../types/sse";
|
||||||
|
import { callSSE } from "../callSSE";
|
||||||
|
import {
|
||||||
|
type EventListener,
|
||||||
|
SSEContext,
|
||||||
|
type SSEContextType,
|
||||||
|
} from "../SSEContext";
|
||||||
|
import { sseAtom } from "../store/sseAtom";
|
||||||
|
|
||||||
|
export const ServerEventsProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||||
|
const sseRef = useRef<ReturnType<typeof callSSE> | null>(null);
|
||||||
|
const listenersRef = useRef<
|
||||||
|
Map<SSEEvent["kind"], Set<(event: SSEEvent) => void>>
|
||||||
|
>(new Map());
|
||||||
|
const [, setSSEState] = useAtom(sseAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sse = callSSE();
|
||||||
|
sseRef.current = sse;
|
||||||
|
|
||||||
|
const { removeEventListener } = sse.addEventListener("connect", (event) => {
|
||||||
|
setSSEState({
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("SSE connected", event);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// clean up
|
||||||
|
sse.cleanUp();
|
||||||
|
removeEventListener();
|
||||||
|
};
|
||||||
|
}, [setSSEState]);
|
||||||
|
|
||||||
|
const addEventListener = useCallback(
|
||||||
|
<T extends SSEEvent["kind"]>(eventType: T, listener: EventListener<T>) => {
|
||||||
|
// Store the listener in our internal map
|
||||||
|
if (!listenersRef.current.has(eventType)) {
|
||||||
|
listenersRef.current.set(eventType, new Set());
|
||||||
|
}
|
||||||
|
const listeners = listenersRef.current.get(eventType);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.add(listener as (event: SSEEvent) => void);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register with the actual SSE connection
|
||||||
|
let sseCleanup: (() => void) | null = null;
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
const registerWithSSE = () => {
|
||||||
|
if (sseRef.current) {
|
||||||
|
const { removeEventListener } = sseRef.current.addEventListener(
|
||||||
|
eventType,
|
||||||
|
(event) => {
|
||||||
|
// The listener expects the specific event type, so we cast it through unknown first
|
||||||
|
listener(event as unknown as Extract<SSEEvent, { kind: T }>);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
sseCleanup = removeEventListener;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register immediately if SSE is ready, or wait for it
|
||||||
|
if (sseRef.current) {
|
||||||
|
registerWithSSE();
|
||||||
|
} else {
|
||||||
|
// Use a small delay to wait for SSE to be initialized
|
||||||
|
timeoutId = setTimeout(registerWithSSE, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
return () => {
|
||||||
|
// Remove from internal listeners
|
||||||
|
const listeners = listenersRef.current.get(eventType);
|
||||||
|
if (listeners) {
|
||||||
|
listeners.delete(listener as (event: SSEEvent) => void);
|
||||||
|
if (listeners.size === 0) {
|
||||||
|
listenersRef.current.delete(eventType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Remove from SSE connection
|
||||||
|
if (sseCleanup) {
|
||||||
|
sseCleanup();
|
||||||
|
}
|
||||||
|
// Clear timeout if it exists
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const contextValue: SSEContextType = {
|
||||||
|
addEventListener,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SSEContext.Provider value={contextValue}>{children}</SSEContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
24
src/lib/sse/hook/useServerEventListener.ts
Normal file
24
src/lib/sse/hook/useServerEventListener.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { SSEEvent } from "../../../types/sse";
|
||||||
|
import { type EventListener, useSSEContext } from "../SSEContext";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook to listen for specific SSE events
|
||||||
|
* @param eventType - The type of event to listen for
|
||||||
|
* @param listener - The callback function to execute when the event is received
|
||||||
|
* @param deps - Dependencies array for the listener function (similar to useEffect)
|
||||||
|
*/
|
||||||
|
export const useServerEventListener = <T extends SSEEvent["kind"]>(
|
||||||
|
eventType: T,
|
||||||
|
listener: EventListener<T>,
|
||||||
|
deps?: React.DependencyList,
|
||||||
|
) => {
|
||||||
|
const { addEventListener } = useSSEContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const removeEventListener = addEventListener(eventType, listener);
|
||||||
|
return () => {
|
||||||
|
removeEventListener();
|
||||||
|
};
|
||||||
|
}, [eventType, addEventListener, listener, ...(deps ?? [])]);
|
||||||
|
};
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import type {
|
|
||||||
ProjectChangedData,
|
|
||||||
SessionChangedData,
|
|
||||||
} from "../../server/service/events/types";
|
|
||||||
|
|
||||||
export interface SSEEventHandlers {
|
|
||||||
onProjectChanged?: (data: ProjectChangedData) => void;
|
|
||||||
onSessionChanged?: (data: SessionChangedData) => void;
|
|
||||||
onConnected?: () => void;
|
|
||||||
onHeartbeat?: (timestamp: string) => void;
|
|
||||||
onError?: (error: Event) => void;
|
|
||||||
onClose?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class SSEClient {
|
|
||||||
private eventSource: EventSource | null = null;
|
|
||||||
private handlers: SSEEventHandlers;
|
|
||||||
private url: string;
|
|
||||||
|
|
||||||
constructor(baseUrl: string = "", handlers: SSEEventHandlers = {}) {
|
|
||||||
this.url = `${baseUrl}/api/events`;
|
|
||||||
this.handlers = handlers;
|
|
||||||
}
|
|
||||||
|
|
||||||
public connect(): void {
|
|
||||||
if (this.eventSource) {
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.eventSource = new EventSource(this.url);
|
|
||||||
|
|
||||||
// 接続確認イベント
|
|
||||||
this.eventSource.addEventListener("connected", (event) => {
|
|
||||||
console.log("SSE Connected:", event.data);
|
|
||||||
this.handlers.onConnected?.();
|
|
||||||
});
|
|
||||||
|
|
||||||
// プロジェクト変更イベント
|
|
||||||
this.eventSource.addEventListener("project_changed", (event) => {
|
|
||||||
try {
|
|
||||||
const data: ProjectChangedData = JSON.parse(event.data);
|
|
||||||
console.log("Project changed:", data);
|
|
||||||
this.handlers.onProjectChanged?.(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse project_changed event:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// セッション変更イベント
|
|
||||||
this.eventSource.addEventListener("session_changed", (event) => {
|
|
||||||
try {
|
|
||||||
const data: SessionChangedData = JSON.parse(event.data);
|
|
||||||
console.log("Session changed:", data);
|
|
||||||
this.handlers.onSessionChanged?.(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse session_changed event:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ハートビートイベント
|
|
||||||
this.eventSource.addEventListener("heartbeat", (event) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
this.handlers.onHeartbeat?.(data.timestamp);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to parse heartbeat event:", error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// エラーハンドリング
|
|
||||||
this.eventSource.onerror = (error) => {
|
|
||||||
console.error("SSE Error:", error);
|
|
||||||
this.handlers.onError?.(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 接続終了
|
|
||||||
this.eventSource.onopen = () => {
|
|
||||||
console.log("SSE Connection opened");
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to establish SSE connection:", error);
|
|
||||||
this.handlers.onError?.(error as Event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public disconnect(): void {
|
|
||||||
if (this.eventSource) {
|
|
||||||
this.eventSource.close();
|
|
||||||
this.eventSource = null;
|
|
||||||
console.log("SSE Connection closed");
|
|
||||||
this.handlers.onClose?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public isConnected(): boolean {
|
|
||||||
return this.eventSource?.readyState === EventSource.OPEN;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// React Hook example
|
|
||||||
export function useSSE(handlers: SSEEventHandlers) {
|
|
||||||
const client = new SSEClient(window?.location?.origin, handlers);
|
|
||||||
|
|
||||||
return {
|
|
||||||
connect: () => client.connect(),
|
|
||||||
disconnect: () => client.disconnect(),
|
|
||||||
isConnected: () => client.isConnected(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
7
src/lib/sse/store/sseAtom.ts
Normal file
7
src/lib/sse/store/sseAtom.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { atom } from "jotai";
|
||||||
|
|
||||||
|
export const sseAtom = atom<{
|
||||||
|
isConnected: boolean;
|
||||||
|
}>({
|
||||||
|
isConnected: false,
|
||||||
|
});
|
||||||
@@ -8,9 +8,11 @@ import { z } from "zod";
|
|||||||
import { configSchema } from "../config/config";
|
import { configSchema } from "../config/config";
|
||||||
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
|
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
|
||||||
import type { SerializableAliveTask } from "../service/claude-code/types";
|
import type { SerializableAliveTask } from "../service/claude-code/types";
|
||||||
|
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
|
||||||
import { getEventBus } from "../service/events/EventBus";
|
import { getEventBus } from "../service/events/EventBus";
|
||||||
import { getFileWatcher } from "../service/events/fileWatcher";
|
import { getFileWatcher } from "../service/events/fileWatcher";
|
||||||
import { sseEventResponse } from "../service/events/sseEventResponse";
|
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
|
||||||
|
import { writeTypeSafeSSE } from "../service/events/typeSafeSSE";
|
||||||
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
|
import { getFileCompletion } from "../service/file-completion/getFileCompletion";
|
||||||
import { getBranches } from "../service/git/getBranches";
|
import { getBranches } from "../service/git/getBranches";
|
||||||
import { getCommits } from "../service/git/getCommits";
|
import { getCommits } from "../service/git/getCommits";
|
||||||
@@ -25,6 +27,14 @@ import { configMiddleware } from "./middleware/config.middleware";
|
|||||||
|
|
||||||
export const routes = (app: HonoAppType) => {
|
export const routes = (app: HonoAppType) => {
|
||||||
const taskController = new ClaudeCodeTaskController();
|
const taskController = new ClaudeCodeTaskController();
|
||||||
|
const fileWatcher = getFileWatcher();
|
||||||
|
const eventBus = getEventBus();
|
||||||
|
|
||||||
|
fileWatcher.startWatching();
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
eventBus.emit("heartbeat", {});
|
||||||
|
}, 10 * 1000);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
app
|
app
|
||||||
@@ -375,108 +385,52 @@ export const routes = (app: HonoAppType) => {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
.get("/events/state_changes", async (c) => {
|
.get("/sse", async (c) => {
|
||||||
return streamSSE(
|
return streamSSE(
|
||||||
c,
|
c,
|
||||||
async (stream) => {
|
async (rawStream) => {
|
||||||
const fileWatcher = getFileWatcher();
|
const stream = writeTypeSafeSSE(rawStream);
|
||||||
const eventBus = getEventBus();
|
|
||||||
|
|
||||||
let isConnected = true;
|
const onSessionListChanged = (
|
||||||
|
event: InternalEventDeclaration["sessionListChanged"],
|
||||||
// ハートビート設定
|
) => {
|
||||||
const heartbeat = setInterval(() => {
|
stream.writeSSE("sessionListChanged", {
|
||||||
if (isConnected) {
|
projectId: event.projectId,
|
||||||
eventBus.emit("heartbeat", {
|
});
|
||||||
type: "heartbeat",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 30 * 1000);
|
|
||||||
|
|
||||||
// connection handling
|
|
||||||
const abortController = new AbortController();
|
|
||||||
let connectionResolve: ((value: undefined) => void) | undefined;
|
|
||||||
const connectionPromise = new Promise<undefined>((resolve) => {
|
|
||||||
connectionResolve = resolve;
|
|
||||||
});
|
|
||||||
|
|
||||||
const onConnectionClosed = () => {
|
|
||||||
isConnected = false;
|
|
||||||
connectionResolve?.(undefined);
|
|
||||||
abortController.abort();
|
|
||||||
clearInterval(heartbeat);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 接続終了時のクリーンアップ
|
const onSessionChanged = (
|
||||||
stream.onAbort(() => {
|
event: InternalEventDeclaration["sessionChanged"],
|
||||||
console.log("SSE connection aborted");
|
) => {
|
||||||
onConnectionClosed();
|
stream.writeSSE("sessionChanged", {
|
||||||
});
|
projectId: event.projectId,
|
||||||
|
sessionId: event.sessionId,
|
||||||
// イベントリスナーを登録
|
|
||||||
console.log("Registering SSE event listeners");
|
|
||||||
eventBus.on("connected", async (event) => {
|
|
||||||
if (!isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await stream.writeSSE(sseEventResponse(event)).catch(() => {
|
|
||||||
onConnectionClosed();
|
|
||||||
});
|
});
|
||||||
});
|
};
|
||||||
|
|
||||||
eventBus.on("heartbeat", async (event) => {
|
const onTaskChanged = (
|
||||||
if (!isConnected) {
|
event: InternalEventDeclaration["taskChanged"],
|
||||||
return;
|
) => {
|
||||||
}
|
stream.writeSSE("taskChanged", {
|
||||||
await stream.writeSSE(sseEventResponse(event)).catch(() => {
|
aliveTasks: event.aliveTasks,
|
||||||
onConnectionClosed();
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
eventBus.on("sessionListChanged", onSessionListChanged);
|
||||||
|
eventBus.on("sessionChanged", onSessionChanged);
|
||||||
|
eventBus.on("taskChanged", onTaskChanged);
|
||||||
|
const { connectionPromise } = adaptInternalEventToSSE(rawStream, {
|
||||||
|
cleanUp: () => {
|
||||||
|
eventBus.off("sessionListChanged", onSessionListChanged);
|
||||||
|
eventBus.off("sessionChanged", onSessionChanged);
|
||||||
|
eventBus.off("taskChanged", onTaskChanged);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
eventBus.on("project_changed", async (event) => {
|
|
||||||
if (!isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await stream.writeSSE(sseEventResponse(event)).catch(() => {
|
|
||||||
console.warn("Failed to write SSE event");
|
|
||||||
onConnectionClosed();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
eventBus.on("session_changed", async (event) => {
|
|
||||||
if (!isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await stream.writeSSE(sseEventResponse(event)).catch(() => {
|
|
||||||
onConnectionClosed();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
eventBus.on("task_changed", async (event) => {
|
|
||||||
if (!isConnected) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await stream.writeSSE(sseEventResponse(event)).catch(() => {
|
|
||||||
onConnectionClosed();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// 初期接続確認メッセージ
|
|
||||||
eventBus.emit("connected", {
|
|
||||||
type: "connected",
|
|
||||||
message: "SSE connection established",
|
|
||||||
});
|
|
||||||
|
|
||||||
fileWatcher.startWatching();
|
|
||||||
|
|
||||||
await connectionPromise;
|
await connectionPromise;
|
||||||
},
|
},
|
||||||
async (err, stream) => {
|
async (err) => {
|
||||||
console.error("Streaming error:", err);
|
console.error("Streaming error:", err);
|
||||||
await stream.write("エラーが発生しました。");
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { execSync } from "node:child_process";
|
|||||||
import { query } from "@anthropic-ai/claude-code";
|
import { query } from "@anthropic-ai/claude-code";
|
||||||
import prexit from "prexit";
|
import prexit from "prexit";
|
||||||
import { ulid } from "ulid";
|
import { ulid } from "ulid";
|
||||||
import { type EventBus, getEventBus } from "../events/EventBus";
|
import { getEventBus, type IEventBus } from "../events/EventBus";
|
||||||
import { createMessageGenerator } from "./createMessageGenerator";
|
import { createMessageGenerator } from "./createMessageGenerator";
|
||||||
import type {
|
import type {
|
||||||
AliveClaudeCodeTask,
|
AliveClaudeCodeTask,
|
||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
export class ClaudeCodeTaskController {
|
export class ClaudeCodeTaskController {
|
||||||
private pathToClaudeCodeExecutable: string;
|
private pathToClaudeCodeExecutable: string;
|
||||||
private tasks: ClaudeCodeTask[] = [];
|
private tasks: ClaudeCodeTask[] = [];
|
||||||
private eventBus: EventBus;
|
private eventBus: IEventBus;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pathToClaudeCodeExecutable = execSync("which claude", {})
|
this.pathToClaudeCodeExecutable = execSync("which claude", {})
|
||||||
@@ -239,9 +239,8 @@ export class ClaudeCodeTaskController {
|
|||||||
|
|
||||||
Object.assign(target, task);
|
Object.assign(target, task);
|
||||||
|
|
||||||
this.eventBus.emit("task_changed", {
|
this.eventBus.emit("taskChanged", {
|
||||||
type: "task_changed",
|
aliveTasks: this.aliveTasks,
|
||||||
data: this.aliveTasks,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,47 @@
|
|||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:stream";
|
||||||
|
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
|
||||||
|
|
||||||
import type { BaseSSEEvent, SSEEvent } from "./types";
|
class EventBus {
|
||||||
|
private emitter: EventEmitter;
|
||||||
|
|
||||||
export class EventBus {
|
constructor() {
|
||||||
private previousId = 0;
|
this.emitter = new EventEmitter();
|
||||||
private eventEmitter = new EventEmitter();
|
}
|
||||||
|
|
||||||
public emit<
|
public emit<EventName extends keyof InternalEventDeclaration>(
|
||||||
T extends SSEEvent["type"],
|
event: EventName,
|
||||||
E = SSEEvent extends infer I ? (I extends { type: T } ? I : never) : never,
|
data: InternalEventDeclaration[EventName],
|
||||||
>(type: T, event: Omit<E, "id" | "timestamp">): void {
|
): void {
|
||||||
const base: BaseSSEEvent = {
|
this.emitter.emit(event, {
|
||||||
id: String(this.previousId++),
|
...data,
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.eventEmitter.emit(type, {
|
|
||||||
...event,
|
|
||||||
...base,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public on(
|
public on<EventName extends keyof InternalEventDeclaration>(
|
||||||
event: SSEEvent["type"],
|
event: EventName,
|
||||||
listener: (event: SSEEvent) => void,
|
listener: (
|
||||||
|
data: InternalEventDeclaration[EventName],
|
||||||
|
) => void | Promise<void>,
|
||||||
): void {
|
): void {
|
||||||
this.eventEmitter.on(event, listener);
|
this.emitter.on(event, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
public off(
|
public off<EventName extends keyof InternalEventDeclaration>(
|
||||||
event: SSEEvent["type"],
|
event: EventName,
|
||||||
listener: (event: SSEEvent) => void,
|
listener: (
|
||||||
|
data: InternalEventDeclaration[EventName],
|
||||||
|
) => void | Promise<void>,
|
||||||
): void {
|
): void {
|
||||||
this.eventEmitter.off(event, listener);
|
this.emitter.off(event, listener);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Singleton
|
// singleton
|
||||||
let eventBusInstance: EventBus | null = null;
|
let eventBus: EventBus | null = null;
|
||||||
|
|
||||||
export const getEventBus = (): EventBus => {
|
export const getEventBus = () => {
|
||||||
eventBusInstance ??= new EventBus();
|
eventBus ??= new EventBus();
|
||||||
return eventBusInstance;
|
return eventBus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IEventBus = ReturnType<typeof getEventBus>;
|
||||||
|
|||||||
19
src/server/service/events/InternalEventDeclaration.ts
Normal file
19
src/server/service/events/InternalEventDeclaration.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AliveClaudeCodeTask } from "../claude-code/types";
|
||||||
|
|
||||||
|
export type InternalEventDeclaration = {
|
||||||
|
// biome-ignore lint/complexity/noBannedTypes: correct type
|
||||||
|
heartbeat: {};
|
||||||
|
|
||||||
|
sessionListChanged: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionChanged: {
|
||||||
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
taskChanged: {
|
||||||
|
aliveTasks: AliveClaudeCodeTask[];
|
||||||
|
};
|
||||||
|
};
|
||||||
61
src/server/service/events/adaptInternalEventToSSE.ts
Normal file
61
src/server/service/events/adaptInternalEventToSSE.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import type { SSEStreamingApi } from "hono/streaming";
|
||||||
|
import { getEventBus } from "./EventBus";
|
||||||
|
import type { InternalEventDeclaration } from "./InternalEventDeclaration";
|
||||||
|
import { writeTypeSafeSSE } from "./typeSafeSSE";
|
||||||
|
|
||||||
|
export const adaptInternalEventToSSE = (
|
||||||
|
rawStream: SSEStreamingApi,
|
||||||
|
options?: {
|
||||||
|
timeout?: number;
|
||||||
|
cleanUp?: () => void | Promise<void>;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const { timeout = 60 * 1000, cleanUp } = options ?? {};
|
||||||
|
|
||||||
|
console.log("SSE connection started");
|
||||||
|
|
||||||
|
const eventBus = getEventBus();
|
||||||
|
|
||||||
|
const stream = writeTypeSafeSSE(rawStream);
|
||||||
|
|
||||||
|
const abortController = new AbortController();
|
||||||
|
let connectionResolve: (() => void) | undefined;
|
||||||
|
const connectionPromise = new Promise<void>((resolve) => {
|
||||||
|
connectionResolve = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const closeConnection = () => {
|
||||||
|
console.log("SSE connection closed");
|
||||||
|
connectionResolve?.();
|
||||||
|
abortController.abort();
|
||||||
|
|
||||||
|
eventBus.off("heartbeat", heartbeat);
|
||||||
|
cleanUp?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
rawStream.onAbort(() => {
|
||||||
|
console.log("SSE connection aborted");
|
||||||
|
closeConnection();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
const heartbeat = (event: InternalEventDeclaration["heartbeat"]) => {
|
||||||
|
stream.writeSSE("heartbeat", {
|
||||||
|
...event,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
eventBus.on("heartbeat", heartbeat);
|
||||||
|
|
||||||
|
stream.writeSSE("connect", {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
closeConnection();
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionPromise,
|
||||||
|
} as const;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type FSWatcher, watch } from "node:fs";
|
import { type FSWatcher, watch } from "node:fs";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { claudeProjectPath } from "../paths";
|
import { claudeProjectPath } from "../paths";
|
||||||
import { type EventBus, getEventBus } from "./EventBus";
|
import { getEventBus, type IEventBus } from "./EventBus";
|
||||||
|
|
||||||
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
|
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
|
||||||
const fileRegExpGroupSchema = z.object({
|
const fileRegExpGroupSchema = z.object({
|
||||||
@@ -10,15 +10,19 @@ const fileRegExpGroupSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export class FileWatcherService {
|
export class FileWatcherService {
|
||||||
|
private isWatching = false;
|
||||||
private watcher: FSWatcher | null = null;
|
private watcher: FSWatcher | null = null;
|
||||||
private projectWatchers: Map<string, FSWatcher> = new Map();
|
private projectWatchers: Map<string, FSWatcher> = new Map();
|
||||||
private eventBus: EventBus;
|
private eventBus: IEventBus;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.eventBus = getEventBus();
|
this.eventBus = getEventBus();
|
||||||
}
|
}
|
||||||
|
|
||||||
public startWatching(): void {
|
public startWatching(): void {
|
||||||
|
if (this.isWatching) return;
|
||||||
|
this.isWatching = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
console.log("Starting file watcher on:", claudeProjectPath);
|
console.log("Starting file watcher on:", claudeProjectPath);
|
||||||
// メインプロジェクトディレクトリを監視
|
// メインプロジェクトディレクトリを監視
|
||||||
@@ -36,22 +40,20 @@ export class FileWatcherService {
|
|||||||
|
|
||||||
const { projectId, sessionId } = groups.data;
|
const { projectId, sessionId } = groups.data;
|
||||||
|
|
||||||
this.eventBus.emit("project_changed", {
|
if (eventType === "change") {
|
||||||
type: "project_changed",
|
// セッションファイルの中身が変更されている
|
||||||
data: {
|
this.eventBus.emit("sessionChanged", {
|
||||||
fileEventType: eventType,
|
|
||||||
projectId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
this.eventBus.emit("session_changed", {
|
|
||||||
type: "session_changed",
|
|
||||||
data: {
|
|
||||||
projectId,
|
projectId,
|
||||||
sessionId,
|
sessionId,
|
||||||
fileEventType: eventType,
|
});
|
||||||
},
|
} else if (eventType === "rename") {
|
||||||
});
|
// セッションファイルの追加/削除
|
||||||
|
this.eventBus.emit("sessionListChanged", {
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
eventType satisfies never;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
console.log("File watcher initialization completed");
|
console.log("File watcher initialization completed");
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import type { SSEEvent } from "./types";
|
|
||||||
|
|
||||||
export const sseEventResponse = (event: SSEEvent) => {
|
|
||||||
return {
|
|
||||||
data: JSON.stringify(event),
|
|
||||||
event: event.type,
|
|
||||||
id: event.id,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
21
src/server/service/events/typeSafeSSE.ts
Normal file
21
src/server/service/events/typeSafeSSE.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import type { SSEStreamingApi } from "hono/streaming";
|
||||||
|
import { ulid } from "ulid";
|
||||||
|
import type { SSEEventDeclaration } from "../../../types/sse";
|
||||||
|
|
||||||
|
export const writeTypeSafeSSE = (stream: SSEStreamingApi) => ({
|
||||||
|
writeSSE: async <EventName extends keyof SSEEventDeclaration>(
|
||||||
|
event: EventName,
|
||||||
|
data: SSEEventDeclaration[EventName],
|
||||||
|
): Promise<void> => {
|
||||||
|
const id = ulid();
|
||||||
|
await stream.writeSSE({
|
||||||
|
event: event,
|
||||||
|
id: id,
|
||||||
|
data: JSON.stringify({
|
||||||
|
kind: event,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...data,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import type { WatchEventType } from "node:fs";
|
|
||||||
import type { SerializableAliveTask } from "../claude-code/types";
|
|
||||||
|
|
||||||
export type WatcherEvent =
|
|
||||||
| {
|
|
||||||
eventType: "project_changed";
|
|
||||||
data: ProjectChangedData;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
eventType: "session_changed";
|
|
||||||
data: SessionChangedData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BaseSSEEvent = {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SSEEvent = BaseSSEEvent &
|
|
||||||
(
|
|
||||||
| {
|
|
||||||
type: "connected";
|
|
||||||
message: string;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "heartbeat";
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "project_changed";
|
|
||||||
data: ProjectChangedData;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "session_changed";
|
|
||||||
data: SessionChangedData;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: "task_changed";
|
|
||||||
data: SerializableAliveTask[];
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export interface ProjectChangedData {
|
|
||||||
projectId: string;
|
|
||||||
fileEventType: WatchEventType;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SessionChangedData {
|
|
||||||
projectId: string;
|
|
||||||
sessionId: string;
|
|
||||||
fileEventType: WatchEventType;
|
|
||||||
}
|
|
||||||
@@ -160,8 +160,6 @@ async function getUntrackedFiles(cwd: string): Promise<GitResult<string[]>> {
|
|||||||
cwd,
|
cwd,
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log("debug statusResult stdout", statusResult);
|
|
||||||
|
|
||||||
if (!statusResult.success) {
|
if (!statusResult.success) {
|
||||||
return statusResult;
|
return statusResult;
|
||||||
}
|
}
|
||||||
@@ -334,7 +332,6 @@ export const getDiff = async (
|
|||||||
// Include untracked files when comparing to working directory
|
// Include untracked files when comparing to working directory
|
||||||
if (toRef === undefined) {
|
if (toRef === undefined) {
|
||||||
const untrackedResult = await getUntrackedFiles(cwd);
|
const untrackedResult = await getUntrackedFiles(cwd);
|
||||||
console.log("debug untrackedResult", untrackedResult);
|
|
||||||
if (untrackedResult.success) {
|
if (untrackedResult.success) {
|
||||||
for (const untrackedFile of untrackedResult.data) {
|
for (const untrackedFile of untrackedResult.data) {
|
||||||
const untrackedDiff = await createUntrackedFileDiff(
|
const untrackedDiff = await createUntrackedFileDiff(
|
||||||
|
|||||||
31
src/types/sse.ts
Normal file
31
src/types/sse.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { AliveClaudeCodeTask } from "../server/service/claude-code/types";
|
||||||
|
|
||||||
|
export type SSEEventDeclaration = {
|
||||||
|
// biome-ignore lint/complexity/noBannedTypes: correct type
|
||||||
|
connect: {};
|
||||||
|
|
||||||
|
// biome-ignore lint/complexity/noBannedTypes: correct type
|
||||||
|
heartbeat: {};
|
||||||
|
|
||||||
|
sessionListChanged: {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionChanged: {
|
||||||
|
projectId: string;
|
||||||
|
sessionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
taskChanged: {
|
||||||
|
aliveTasks: AliveClaudeCodeTask[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSEEventMap = {
|
||||||
|
[K in keyof SSEEventDeclaration]: SSEEventDeclaration[K] & {
|
||||||
|
kind: K;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSEEvent = SSEEventMap[keyof SSEEventDeclaration];
|
||||||
Reference in New Issue
Block a user