diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx new file mode 100644 index 0000000..047724a --- /dev/null +++ b/src/app/components/SSEEventListeners.tsx @@ -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 = ({ 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}; +}; diff --git a/src/app/components/ServerEventsProvider.tsx b/src/app/components/ServerEventsProvider.tsx deleted file mode 100644 index 4181b64..0000000 --- a/src/app/components/ServerEventsProvider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -"use client"; - -import { useServerEvents } from "@/hooks/useServerEvents"; - -interface ServerEventsProviderProps { - children: React.ReactNode; -} - -export function ServerEventsProvider({ children }: ServerEventsProviderProps) { - useServerEvents(); - - return <>{children}; -} diff --git a/src/app/hooks/useConfig.ts b/src/app/hooks/useConfig.ts index aa308ce..72b535a 100644 --- a/src/app/hooks/useConfig.ts +++ b/src/app/hooks/useConfig.ts @@ -5,21 +5,15 @@ import { } from "@tanstack/react-query"; import { useCallback } from "react"; import { honoClient } from "../../lib/api/client"; +import { configQuery } from "../../lib/api/queries"; 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 = () => { const queryClient = useQueryClient(); const { data } = useSuspenseQuery({ - ...configQueryConfig, + queryKey: configQuery.queryKey, + queryFn: configQuery.queryFn, }); const updateConfigMutation = useMutation({ mutationFn: async (config: Config) => { @@ -30,7 +24,7 @@ export const useConfig = () => { }, onSuccess: async () => { await queryClient.invalidateQueries({ - queryKey: configQueryConfig.queryKey, + queryKey: configQuery.queryKey, }); }, }); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 0aa6bf9..7e98362 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,13 +1,15 @@ +import { QueryClient } from "@tanstack/react-query"; import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; + import { Toaster } from "../components/ui/sonner"; import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper"; +import { SSEProvider } from "../lib/sse/components/SSEProvider"; import { RootErrorBoundary } from "./components/RootErrorBoundary"; -import { ServerEventsProvider } from "./components/ServerEventsProvider"; import "./globals.css"; -import { QueryClient } from "@tanstack/react-query"; -import { configQueryConfig } from "./hooks/useConfig"; +import { configQuery } from "../lib/api/queries"; +import { SSEEventListeners } from "./components/SSEEventListeners"; export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; @@ -35,7 +37,8 @@ export default async function RootLayout({ const queryClient = new QueryClient(); await queryClient.prefetchQuery({ - ...configQueryConfig, + queryKey: configQuery.queryKey, + queryFn: configQuery.queryFn, }); return ( @@ -45,7 +48,9 @@ export default async function RootLayout({ > - {children} + + {children} + diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx index 49619b5..24d37be 100644 --- a/src/app/projects/[projectId]/components/ProjectPage.tsx +++ b/src/app/projects/[projectId]/components/ProjectPage.tsx @@ -25,8 +25,9 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; +import { projectDetailQuery } from "../../../../lib/api/queries"; import { useConfig } from "../../../hooks/useConfig"; -import { projectQueryConfig, useProject } from "../hooks/useProject"; +import { useProject } from "../hooks/useProject"; import { firstCommandToTitle } from "../services/firstCommandToTitle"; import { NewChatModal } from "./newChat/NewChatModal"; @@ -41,7 +42,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed useEffect(() => { void queryClient.invalidateQueries({ - queryKey: projectQueryConfig(projectId).queryKey, + queryKey: projectDetailQuery(projectId).queryKey, }); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]); diff --git a/src/app/projects/[projectId]/components/chatForm/CommandCompletion.tsx b/src/app/projects/[projectId]/components/chatForm/CommandCompletion.tsx index d36e64a..7b5d931 100644 --- a/src/app/projects/[projectId]/components/chatForm/CommandCompletion.tsx +++ b/src/app/projects/[projectId]/components/chatForm/CommandCompletion.tsx @@ -15,7 +15,7 @@ import { Collapsible, CollapsibleContent, } from "../../../../../components/ui/collapsible"; -import { honoClient } from "../../../../../lib/api/client"; +import { claudeCommandsQuery } from "../../../../../lib/api/queries"; import { cn } from "../../../../../lib/utils"; type CommandCompletionProps = { @@ -40,18 +40,8 @@ export const CommandCompletion = forwardRef< // コマンドリストを取得 const { data: commandData } = useQuery({ - 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 commands"); - } - return response.json(); - }, + queryKey: claudeCommandsQuery(projectId).queryKey, + queryFn: claudeCommandsQuery(projectId).queryFn, staleTime: 1000 * 60 * 5, // 5分間キャッシュ }); diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts index cbe0759..7ea840d 100644 --- a/src/app/projects/[projectId]/hooks/useProject.ts +++ b/src/app/projects/[projectId]/hooks/useProject.ts @@ -1,21 +1,10 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { honoClient } from "../../../../lib/api/client"; - -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; +import { projectDetailQuery } from "../../../../lib/api/queries"; export const useProject = (projectId: string) => { return useSuspenseQuery({ - ...projectQueryConfig(projectId), + queryKey: projectDetailQuery(projectId).queryKey, + queryFn: projectDetailQuery(projectId).queryFn, refetchOnReconnect: true, }); }; diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 27d9f41..ec66435 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -3,8 +3,8 @@ import { HydrationBoundary, QueryClient, } from "@tanstack/react-query"; +import { projectDetailQuery } from "../../../lib/api/queries"; import { ProjectPageContent } from "./components/ProjectPage"; -import { projectQueryConfig } from "./hooks/useProject"; interface ProjectPageProps { params: Promise<{ projectId: string }>; @@ -16,7 +16,8 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const queryClient = new QueryClient(); await queryClient.prefetchQuery({ - ...projectQueryConfig(projectId), + queryKey: projectDetailQuery(projectId).queryKey, + queryFn: projectDetailQuery(projectId).queryFn, }); return ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx index 5f42af6..b079fe1 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/McpTab.tsx @@ -4,7 +4,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 { honoClient } from "@/lib/api/client"; +import { mcpListQuery } from "../../../../../../../lib/api/queries"; export const McpTab: FC = () => { const queryClient = useQueryClient(); @@ -14,18 +14,12 @@ export const McpTab: FC = () => { isLoading, error, } = useQuery({ - queryKey: ["mcp", "list"], - queryFn: async () => { - const response = await honoClient.api.mcp.list.$get(); - if (!response.ok) { - throw new Error("Failed to fetch MCP servers"); - } - return response.json(); - }, + queryKey: mcpListQuery.queryKey, + queryFn: mcpListQuery.queryFn, }); const handleReload = () => { - queryClient.invalidateQueries({ queryKey: ["mcp", "list"] }); + queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey }); }; return ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useAliveTask.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useAliveTask.ts index f13709e..6a02165 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useAliveTask.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useAliveTask.ts @@ -1,24 +1,18 @@ import { useQuery } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useMemo } from "react"; -import { honoClient } from "../../../../../../lib/api/client"; +import { aliveTasksQuery } from "../../../../../../lib/api/queries"; import { aliveTasksAtom } from "../store/aliveTasksAtom"; export const useAliveTask = (sessionId: string) => { const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom); useQuery({ - queryKey: ["aliveTasks"], + queryKey: aliveTasksQuery.queryKey, queryFn: async () => { - const response = await honoClient.api.tasks.alive.$get({}); - - if (!response.ok) { - throw new Error(response.statusText); - } - - const data = await response.json(); - setAliveTasks(data.aliveTasks); - return response.json(); + const { aliveTasks } = await aliveTasksQuery.queryFn(); + setAliveTasks(aliveTasks); + return aliveTasks; }, refetchOnReconnect: true, }); diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts index 865d3e2..961ae04 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useGit.ts @@ -1,42 +1,22 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { honoClient } from "@/lib/api/client"; +import { + gitBranchesQuery, + gitCommitsQuery, +} from "../../../../../../lib/api/queries"; export const useGitBranches = (projectId: string) => { return useQuery({ - 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 response.json(); - }, + queryKey: gitBranchesQuery(projectId).queryKey, + queryFn: gitBranchesQuery(projectId).queryFn, staleTime: 30000, // 30 seconds }); }; export const useGitCommits = (projectId: string) => { return useQuery({ - 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 response.json(); - }, + queryKey: gitCommitsQuery(projectId).queryKey, + queryFn: gitCommitsQuery(projectId).queryFn, staleTime: 30000, // 30 seconds }); }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts index 47ace6b..777041e 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts @@ -1,25 +1,9 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { honoClient } from "../../../../../../lib/api/client"; - -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; +import { sessionDetailQuery } from "../../../../../../lib/api/queries"; export const useSessionQuery = (projectId: string, sessionId: string) => { return useSuspenseQuery({ - ...sessionQueryConfig(projectId, sessionId), + queryKey: sessionDetailQuery(projectId, sessionId).queryKey, + queryFn: sessionDetailQuery(projectId, sessionId).queryFn, }); }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx index 2dc88c8..529f574 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx @@ -1,8 +1,10 @@ import { QueryClient } from "@tanstack/react-query"; import type { Metadata } from "next"; -import { projectQueryConfig } from "../../hooks/useProject"; +import { + projectDetailQuery, + sessionDetailQuery, +} from "../../../../../lib/api/queries"; import { SessionPageContent } from "./components/SessionPageContent"; -import { sessionQueryConfig } from "./hooks/useSessionQuery"; type PageParams = { projectId: string; @@ -19,11 +21,12 @@ export async function generateMetadata({ const queryClient = new QueryClient(); await queryClient.prefetchQuery({ - ...sessionQueryConfig(projectId, sessionId), + ...sessionDetailQuery(projectId, sessionId), }); await queryClient.prefetchQuery({ - ...projectQueryConfig(projectId), + queryKey: projectDetailQuery(projectId).queryKey, + queryFn: projectDetailQuery(projectId).queryFn, }); return { diff --git a/src/app/projects/components/ProjectList.tsx b/src/app/projects/components/ProjectList.tsx index 291849c..98e5038 100644 --- a/src/app/projects/components/ProjectList.tsx +++ b/src/app/projects/components/ProjectList.tsx @@ -14,7 +14,9 @@ import { import { useProjects } from "../hooks/useProjects"; export const ProjectList: FC = () => { - const { data: projects } = useProjects(); + const { + data: { projects }, + } = useProjects(); if (projects.length === 0) { diff --git a/src/app/projects/hooks/useProjects.ts b/src/app/projects/hooks/useProjects.ts index 489a9ff..789a9ea 100644 --- a/src/app/projects/hooks/useProjects.ts +++ b/src/app/projects/hooks/useProjects.ts @@ -1,18 +1,9 @@ import { useSuspenseQuery } from "@tanstack/react-query"; -import { honoClient } from "../../../lib/api/client"; - -export const projetsQueryConfig = { - queryKey: ["projects"], - queryFn: async () => { - const response = await honoClient.api.projects.$get(); - const { projects } = await response.json(); - return projects; - }, -} as const; +import { projectListQuery } from "../../../lib/api/queries"; export const useProjects = () => { return useSuspenseQuery({ - queryKey: projetsQueryConfig.queryKey, - queryFn: projetsQueryConfig.queryFn, + queryKey: projectListQuery.queryKey, + queryFn: projectListQuery.queryFn, }); }; diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 9696619..bb8602f 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,7 +1,7 @@ import { QueryClient } from "@tanstack/react-query"; import { HistoryIcon } from "lucide-react"; +import { projectListQuery } from "../../lib/api/queries"; import { ProjectList } from "./components/ProjectList"; -import { projetsQueryConfig } from "./hooks/useProjects"; export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; @@ -10,8 +10,8 @@ export default async function ProjectsPage() { const queryClient = new QueryClient(); await queryClient.prefetchQuery({ - queryKey: projetsQueryConfig.queryKey, - queryFn: projetsQueryConfig.queryFn, + queryKey: projectListQuery.queryKey, + queryFn: projectListQuery.queryFn, }); return ( diff --git a/src/components/SettingsControls.tsx b/src/components/SettingsControls.tsx index c433d8e..f02b935 100644 --- a/src/components/SettingsControls.tsx +++ b/src/components/SettingsControls.tsx @@ -2,7 +2,7 @@ import { useQueryClient } from "@tanstack/react-query"; 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 { Select, @@ -11,7 +11,11 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { projectQueryConfig } from "../app/projects/[projectId]/hooks/useProject"; +import { + configQuery, + projectDetailQuery, + projectListQuery, +} from "../lib/api/queries"; interface SettingsControlsProps { openingProjectId: string; @@ -33,13 +37,13 @@ export const SettingsControls: FC = ({ const onConfigChanged = useCallback(async () => { await queryClient.invalidateQueries({ - queryKey: configQueryConfig.queryKey, + queryKey: configQuery.queryKey, }); await queryClient.invalidateQueries({ - queryKey: ["projects"], + queryKey: projectListQuery.queryKey, }); void queryClient.invalidateQueries({ - queryKey: projectQueryConfig(openingProjectId).queryKey, + queryKey: projectDetailQuery(openingProjectId).queryKey, }); }, [queryClient, openingProjectId]); diff --git a/src/hooks/useFileCompletion.ts b/src/hooks/useFileCompletion.ts index 98059e7..bfbc255 100644 --- a/src/hooks/useFileCompletion.ts +++ b/src/hooks/useFileCompletion.ts @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { honoClient } from "../lib/api/client"; +import { fileCompletionQuery } from "../lib/api/queries"; export type FileCompletionEntry = { name: string; @@ -19,21 +19,8 @@ export const useFileCompletion = ( enabled = true, ) => { return useQuery({ - queryKey: ["file-completion", projectId, basePath], - queryFn: async (): Promise => { - 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(); - }, + queryKey: fileCompletionQuery(projectId, basePath).queryKey, + queryFn: fileCompletionQuery(projectId, basePath).queryFn, enabled: enabled && !!projectId, staleTime: 1000 * 60 * 5, // 5分間キャッシュ }); diff --git a/src/hooks/useServerEvents.ts b/src/hooks/useServerEvents.ts deleted file mode 100644 index 1416746..0000000 --- a/src/hooks/useServerEvents.ts +++ /dev/null @@ -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]); -}; diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts new file mode 100644 index 0000000..297921a --- /dev/null +++ b/src/lib/api/queries.ts @@ -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 => { + 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; diff --git a/src/lib/sse/SSEContext.ts b/src/lib/sse/SSEContext.ts new file mode 100644 index 0000000..9ee9cd4 --- /dev/null +++ b/src/lib/sse/SSEContext.ts @@ -0,0 +1,25 @@ +"use client"; + +import { createContext, useContext } from "react"; +import type { SSEEvent } from "../../types/sse"; + +export type EventListener = ( + event: Extract, +) => void; + +export type SSEContextType = { + addEventListener: ( + eventType: T, + listener: EventListener, + ) => () => void; +}; + +export const SSEContext = createContext(null); + +export const useSSEContext = () => { + const context = useContext(SSEContext); + if (!context) { + throw new Error("useSSEContext must be used within SSEProvider"); + } + return context; +}; diff --git a/src/lib/sse/callSSE.ts b/src/lib/sse/callSSE.ts new file mode 100644 index 0000000..49a2efe --- /dev/null +++ b/src/lib/sse/callSSE.ts @@ -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: 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; +}; diff --git a/src/lib/sse/components/SSEProvider.tsx b/src/lib/sse/components/SSEProvider.tsx new file mode 100644 index 0000000..f20d3e5 --- /dev/null +++ b/src/lib/sse/components/SSEProvider.tsx @@ -0,0 +1,8 @@ +"use client"; + +import type { FC, PropsWithChildren } from "react"; +import { ServerEventsProvider } from "./ServerEventsProvider"; + +export const SSEProvider: FC = ({ children }) => { + return {children}; +}; diff --git a/src/lib/sse/components/ServerEventsProvider.tsx b/src/lib/sse/components/ServerEventsProvider.tsx new file mode 100644 index 0000000..5018c32 --- /dev/null +++ b/src/lib/sse/components/ServerEventsProvider.tsx @@ -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 = ({ children }) => { + const sseRef = useRef | null>(null); + const listenersRef = useRef< + Map 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( + (eventType: T, listener: EventListener) => { + // 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); + }, + ); + 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 ( + {children} + ); +}; diff --git a/src/lib/sse/hook/useServerEventListener.ts b/src/lib/sse/hook/useServerEventListener.ts new file mode 100644 index 0000000..293bf12 --- /dev/null +++ b/src/lib/sse/hook/useServerEventListener.ts @@ -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 = ( + eventType: T, + listener: EventListener, + deps?: React.DependencyList, +) => { + const { addEventListener } = useSSEContext(); + + useEffect(() => { + const removeEventListener = addEventListener(eventType, listener); + return () => { + removeEventListener(); + }; + }, [eventType, addEventListener, listener, ...(deps ?? [])]); +}; diff --git a/src/lib/sse/sseClient.ts b/src/lib/sse/sseClient.ts deleted file mode 100644 index b59bf46..0000000 --- a/src/lib/sse/sseClient.ts +++ /dev/null @@ -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(), - }; -} diff --git a/src/lib/sse/store/sseAtom.ts b/src/lib/sse/store/sseAtom.ts new file mode 100644 index 0000000..d97fca9 --- /dev/null +++ b/src/lib/sse/store/sseAtom.ts @@ -0,0 +1,7 @@ +import { atom } from "jotai"; + +export const sseAtom = atom<{ + isConnected: boolean; +}>({ + isConnected: false, +}); diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 193e68e..ef443a1 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -8,9 +8,11 @@ import { z } from "zod"; import { configSchema } from "../config/config"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; +import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { getEventBus } from "../service/events/EventBus"; 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 { getBranches } from "../service/git/getBranches"; import { getCommits } from "../service/git/getCommits"; @@ -25,6 +27,14 @@ import { configMiddleware } from "./middleware/config.middleware"; export const routes = (app: HonoAppType) => { const taskController = new ClaudeCodeTaskController(); + const fileWatcher = getFileWatcher(); + const eventBus = getEventBus(); + + fileWatcher.startWatching(); + + setInterval(() => { + eventBus.emit("heartbeat", {}); + }, 10 * 1000); return ( app @@ -375,108 +385,52 @@ export const routes = (app: HonoAppType) => { }, ) - .get("/events/state_changes", async (c) => { + .get("/sse", async (c) => { return streamSSE( c, - async (stream) => { - const fileWatcher = getFileWatcher(); - const eventBus = getEventBus(); + async (rawStream) => { + const stream = writeTypeSafeSSE(rawStream); - let isConnected = true; - - // ハートビート設定 - const heartbeat = setInterval(() => { - if (isConnected) { - eventBus.emit("heartbeat", { - type: "heartbeat", - }); - } - }, 30 * 1000); - - // connection handling - const abortController = new AbortController(); - let connectionResolve: ((value: undefined) => void) | undefined; - const connectionPromise = new Promise((resolve) => { - connectionResolve = resolve; - }); - - const onConnectionClosed = () => { - isConnected = false; - connectionResolve?.(undefined); - abortController.abort(); - clearInterval(heartbeat); + const onSessionListChanged = ( + event: InternalEventDeclaration["sessionListChanged"], + ) => { + stream.writeSSE("sessionListChanged", { + projectId: event.projectId, + }); }; - // 接続終了時のクリーンアップ - stream.onAbort(() => { - console.log("SSE connection aborted"); - onConnectionClosed(); - }); - - // イベントリスナーを登録 - console.log("Registering SSE event listeners"); - eventBus.on("connected", async (event) => { - if (!isConnected) { - return; - } - await stream.writeSSE(sseEventResponse(event)).catch(() => { - onConnectionClosed(); + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { + stream.writeSSE("sessionChanged", { + projectId: event.projectId, + sessionId: event.sessionId, }); - }); + }; - eventBus.on("heartbeat", async (event) => { - if (!isConnected) { - return; - } - await stream.writeSSE(sseEventResponse(event)).catch(() => { - onConnectionClosed(); + const onTaskChanged = ( + event: InternalEventDeclaration["taskChanged"], + ) => { + stream.writeSSE("taskChanged", { + aliveTasks: event.aliveTasks, }); + }; + + 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; }, - async (err, stream) => { + async (err) => { console.error("Streaming error:", err); - await stream.write("エラーが発生しました。"); }, ); }) diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 1591566..2b78859 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -2,7 +2,7 @@ import { execSync } from "node:child_process"; import { query } from "@anthropic-ai/claude-code"; import prexit from "prexit"; import { ulid } from "ulid"; -import { type EventBus, getEventBus } from "../events/EventBus"; +import { getEventBus, type IEventBus } from "../events/EventBus"; import { createMessageGenerator } from "./createMessageGenerator"; import type { AliveClaudeCodeTask, @@ -14,7 +14,7 @@ import type { export class ClaudeCodeTaskController { private pathToClaudeCodeExecutable: string; private tasks: ClaudeCodeTask[] = []; - private eventBus: EventBus; + private eventBus: IEventBus; constructor() { this.pathToClaudeCodeExecutable = execSync("which claude", {}) @@ -239,9 +239,8 @@ export class ClaudeCodeTaskController { Object.assign(target, task); - this.eventBus.emit("task_changed", { - type: "task_changed", - data: this.aliveTasks, + this.eventBus.emit("taskChanged", { + aliveTasks: this.aliveTasks, }); } } diff --git a/src/server/service/events/EventBus.ts b/src/server/service/events/EventBus.ts index 3d5a4c7..42fa9e4 100644 --- a/src/server/service/events/EventBus.ts +++ b/src/server/service/events/EventBus.ts @@ -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 { - private previousId = 0; - private eventEmitter = new EventEmitter(); + constructor() { + this.emitter = new EventEmitter(); + } - public emit< - T extends SSEEvent["type"], - E = SSEEvent extends infer I ? (I extends { type: T } ? I : never) : never, - >(type: T, event: Omit): void { - const base: BaseSSEEvent = { - id: String(this.previousId++), - timestamp: new Date().toISOString(), - }; - - this.eventEmitter.emit(type, { - ...event, - ...base, + public emit( + event: EventName, + data: InternalEventDeclaration[EventName], + ): void { + this.emitter.emit(event, { + ...data, }); } - public on( - event: SSEEvent["type"], - listener: (event: SSEEvent) => void, + public on( + event: EventName, + listener: ( + data: InternalEventDeclaration[EventName], + ) => void | Promise, ): void { - this.eventEmitter.on(event, listener); + this.emitter.on(event, listener); } - public off( - event: SSEEvent["type"], - listener: (event: SSEEvent) => void, + public off( + event: EventName, + listener: ( + data: InternalEventDeclaration[EventName], + ) => void | Promise, ): void { - this.eventEmitter.off(event, listener); + this.emitter.off(event, listener); } } -// Singleton -let eventBusInstance: EventBus | null = null; +// singleton +let eventBus: EventBus | null = null; -export const getEventBus = (): EventBus => { - eventBusInstance ??= new EventBus(); - return eventBusInstance; +export const getEventBus = () => { + eventBus ??= new EventBus(); + return eventBus; }; + +export type IEventBus = ReturnType; diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts new file mode 100644 index 0000000..4f12e31 --- /dev/null +++ b/src/server/service/events/InternalEventDeclaration.ts @@ -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[]; + }; +}; diff --git a/src/server/service/events/adaptInternalEventToSSE.ts b/src/server/service/events/adaptInternalEventToSSE.ts new file mode 100644 index 0000000..ba15e74 --- /dev/null +++ b/src/server/service/events/adaptInternalEventToSSE.ts @@ -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; + }, +) => { + 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((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; +}; diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts index d1d44ae..aa1351d 100644 --- a/src/server/service/events/fileWatcher.ts +++ b/src/server/service/events/fileWatcher.ts @@ -1,7 +1,7 @@ import { type FSWatcher, watch } from "node:fs"; import z from "zod"; import { claudeProjectPath } from "../paths"; -import { type EventBus, getEventBus } from "./EventBus"; +import { getEventBus, type IEventBus } from "./EventBus"; const fileRegExp = /(?.*?)\/(?.*?)\.jsonl/; const fileRegExpGroupSchema = z.object({ @@ -10,15 +10,19 @@ const fileRegExpGroupSchema = z.object({ }); export class FileWatcherService { + private isWatching = false; private watcher: FSWatcher | null = null; private projectWatchers: Map = new Map(); - private eventBus: EventBus; + private eventBus: IEventBus; constructor() { this.eventBus = getEventBus(); } public startWatching(): void { + if (this.isWatching) return; + this.isWatching = true; + try { console.log("Starting file watcher on:", claudeProjectPath); // メインプロジェクトディレクトリを監視 @@ -36,22 +40,20 @@ export class FileWatcherService { const { projectId, sessionId } = groups.data; - this.eventBus.emit("project_changed", { - type: "project_changed", - data: { - fileEventType: eventType, - projectId, - }, - }); - - this.eventBus.emit("session_changed", { - type: "session_changed", - data: { + if (eventType === "change") { + // セッションファイルの中身が変更されている + this.eventBus.emit("sessionChanged", { projectId, sessionId, - fileEventType: eventType, - }, - }); + }); + } else if (eventType === "rename") { + // セッションファイルの追加/削除 + this.eventBus.emit("sessionListChanged", { + projectId, + }); + } else { + eventType satisfies never; + } }, ); console.log("File watcher initialization completed"); diff --git a/src/server/service/events/sseEventResponse.ts b/src/server/service/events/sseEventResponse.ts deleted file mode 100644 index 541b584..0000000 --- a/src/server/service/events/sseEventResponse.ts +++ /dev/null @@ -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, - }; -}; diff --git a/src/server/service/events/typeSafeSSE.ts b/src/server/service/events/typeSafeSSE.ts new file mode 100644 index 0000000..4e0e610 --- /dev/null +++ b/src/server/service/events/typeSafeSSE.ts @@ -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 ( + event: EventName, + data: SSEEventDeclaration[EventName], + ): Promise => { + const id = ulid(); + await stream.writeSSE({ + event: event, + id: id, + data: JSON.stringify({ + kind: event, + timestamp: new Date().toISOString(), + ...data, + }), + }); + }, +}); diff --git a/src/server/service/events/types.ts b/src/server/service/events/types.ts deleted file mode 100644 index 83e8e62..0000000 --- a/src/server/service/events/types.ts +++ /dev/null @@ -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; -} diff --git a/src/server/service/git/getDiff.ts b/src/server/service/git/getDiff.ts index 6082036..d7cf1ee 100644 --- a/src/server/service/git/getDiff.ts +++ b/src/server/service/git/getDiff.ts @@ -160,8 +160,6 @@ async function getUntrackedFiles(cwd: string): Promise> { cwd, ); - console.log("debug statusResult stdout", statusResult); - if (!statusResult.success) { return statusResult; } @@ -334,7 +332,6 @@ export const getDiff = async ( // Include untracked files when comparing to working directory if (toRef === undefined) { const untrackedResult = await getUntrackedFiles(cwd); - console.log("debug untrackedResult", untrackedResult); if (untrackedResult.success) { for (const untrackedFile of untrackedResult.data) { const untrackedDiff = await createUntrackedFileDiff( diff --git a/src/types/sse.ts b/src/types/sse.ts new file mode 100644 index 0000000..08aae42 --- /dev/null +++ b/src/types/sse.ts @@ -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];