diff --git a/package.json b/package.json index 5b7b550..7bab04b 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,6 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "hono": "^4.9.5", - "jotai": "^2.13.1", "lucide-react": "^0.542.0", "next": "15.5.2", "react": "^19.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9df31a..4b64fd5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,6 @@ importers: hono: specifier: ^4.9.5 version: 4.9.5 - jotai: - specifier: ^2.13.1 - version: 2.13.1(@types/react@19.1.12)(react@19.1.1) lucide-react: specifier: ^0.542.0 version: 0.542.0(react@19.1.1) @@ -2102,24 +2099,6 @@ packages: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true - jotai@2.13.1: - resolution: {integrity: sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==} - engines: {node: '>=12.20.0'} - peerDependencies: - '@babel/core': '>=7.0.0' - '@babel/template': '>=7.0.0' - '@types/react': '>=17.0.0' - react: '>=17.0.0' - peerDependenciesMeta: - '@babel/core': - optional: true - '@babel/template': - optional: true - '@types/react': - optional: true - react: - optional: true - js-tokens@9.0.1: resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} @@ -4956,11 +4935,6 @@ snapshots: jiti@2.5.1: {} - jotai@2.13.1(@types/react@19.1.12)(react@19.1.1): - optionalDependencies: - '@types/react': 19.1.12 - react: 19.1.1 - js-tokens@9.0.1: {} json-parse-even-better-errors@4.0.0: {} diff --git a/src/app/hooks/useConfig.ts b/src/app/hooks/useConfig.ts new file mode 100644 index 0000000..b7bff0c --- /dev/null +++ b/src/app/hooks/useConfig.ts @@ -0,0 +1,45 @@ +import { + useMutation, + useQueryClient, + useSuspenseQuery, +} from "@tanstack/react-query"; +import { useCallback } from "react"; +import { honoClient } from "../../lib/api/client"; +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, + }); + const updateConfigMutation = useMutation({ + mutationFn: async (config: Config) => { + const response = await honoClient.api.config.$put({ + json: config, + }); + return await response.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: configQueryConfig.queryKey }); + }, + }); + + return { + config: data?.config, + updateConfig: useCallback( + (config: Config) => { + updateConfigMutation.mutate(config); + }, + [updateConfigMutation], + ), + } as const; +}; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 9198bce..9b74311 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,11 @@ import { RootErrorBoundary } from "./components/RootErrorBoundary"; import { ServerEventsProvider } from "./components/ServerEventsProvider"; import "./globals.css"; +import { QueryClient } from "@tanstack/react-query"; +import { configQueryConfig } from "./hooks/useConfig"; + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -21,11 +26,17 @@ export const metadata: Metadata = { description: "Web Viewer for Claude Code history", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + ...configQueryConfig, + }); + return ( { @@ -28,13 +28,15 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { const { data: { project, sessions }, } = useProject(projectId); - const [hideSessionsWithoutUserMessages, setHideSessionsWithoutUserMessages] = - useAtom(hideSessionsWithoutUserMessagesAtom); + const { config, updateConfig } = useConfig(); + const queryClient = useQueryClient(); - // Apply filtering - const filteredSessions = hideSessionsWithoutUserMessages - ? sessions.filter((session) => session.meta.firstCommand !== null) - : sessions; + // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed + useEffect(() => { + void queryClient.invalidateQueries({ + queryKey: projectQueryConfig(projectId).queryKey, + }); + }, [config.hideNoUserMessageSession]); return (
@@ -72,13 +74,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {

Conversation Sessions{" "} - {filteredSessions.length > 0 ? `(${filteredSessions.length})` : ""} - {hideSessionsWithoutUserMessages && - filteredSessions.length !== sessions.length && ( - - of {sessions.length} total - - )} + {sessions.length > 0 ? `(${sessions.length})` : ""}

{/* Filter Controls */} @@ -86,8 +82,16 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
{ + updateConfig({ + ...config, + hideNoUserMessageSession: !config?.hideNoUserMessageSession, + }); + await queryClient.invalidateQueries({ + queryKey: configQueryConfig.queryKey, + }); + }} />
- {filteredSessions.length === 0 ? ( + {sessions.length === 0 ? ( @@ -124,7 +128,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { ) : (
- {filteredSessions.map((session) => ( + {sessions.map((session) => ( { - return useSuspenseQuery({ +export const projectQueryConfig = (projectId: string) => + ({ queryKey: ["projects", projectId], queryFn: async () => { const response = await honoClient.api.projects[":projectId"].$get({ @@ -11,6 +11,11 @@ export const useProject = (projectId: string) => { return await response.json(); }, + }) as const; + +export const useProject = (projectId: string) => { + return useSuspenseQuery({ + ...projectQueryConfig(projectId), refetchOnReconnect: true, }); }; diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index 7542bff..27d9f41 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -1,4 +1,10 @@ +import { + dehydrate, + HydrationBoundary, + QueryClient, +} from "@tanstack/react-query"; import { ProjectPageContent } from "./components/ProjectPage"; +import { projectQueryConfig } from "./hooks/useProject"; interface ProjectPageProps { params: Promise<{ projectId: string }>; @@ -6,5 +12,16 @@ interface ProjectPageProps { export default async function ProjectPage({ params }: ProjectPageProps) { const { projectId } = await params; - return ; + + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + ...projectQueryConfig(projectId), + }); + + return ( + + + + ); } diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts index 57234c6..47ace6b 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts +++ b/src/app/projects/[projectId]/sessions/[sessionId]/hooks/useSessionQuery.ts @@ -1,8 +1,8 @@ import { useSuspenseQuery } from "@tanstack/react-query"; import { honoClient } from "../../../../../../lib/api/client"; -export const useSessionQuery = (projectId: string, sessionId: string) => { - return useSuspenseQuery({ +export const sessionQueryConfig = (projectId: string, sessionId: string) => + ({ queryKey: ["sessions", sessionId], queryFn: async () => { const response = await honoClient.api.projects[":projectId"].sessions[ @@ -13,7 +13,13 @@ export const useSessionQuery = (projectId: string, sessionId: string) => { sessionId, }, }); + return response.json(); }, + }) as const; + +export const useSessionQuery = (projectId: string, sessionId: string) => { + return useSuspenseQuery({ + ...sessionQueryConfig(projectId, sessionId), }); }; diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx index bebdb95..2dc88c8 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/page.tsx @@ -1,5 +1,8 @@ +import { QueryClient } from "@tanstack/react-query"; import type { Metadata } from "next"; +import { projectQueryConfig } from "../../hooks/useProject"; import { SessionPageContent } from "./components/SessionPageContent"; +import { sessionQueryConfig } from "./hooks/useSessionQuery"; type PageParams = { projectId: string; @@ -12,6 +15,17 @@ export async function generateMetadata({ params: Promise; }): Promise { const { projectId, sessionId } = await params; + + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + ...sessionQueryConfig(projectId, sessionId), + }); + + await queryClient.prefetchQuery({ + ...projectQueryConfig(projectId), + }); + return { title: `Session: ${sessionId.slice(0, 8)}...`, description: `View conversation session ${projectId}/${sessionId}`, diff --git a/src/app/projects/[projectId]/store/filterAtoms.ts b/src/app/projects/[projectId]/store/filterAtoms.ts deleted file mode 100644 index 28650f8..0000000 --- a/src/app/projects/[projectId]/store/filterAtoms.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { atom } from "jotai"; -import { atomWithStorage } from "jotai/utils"; - -export type SessionFilterOptions = { - hideSessionsWithoutUserMessages: boolean; -}; - -export const sessionFilterAtom = atomWithStorage( - "session-filters", - { - hideSessionsWithoutUserMessages: true, - }, -); - -export const hideSessionsWithoutUserMessagesAtom = atom( - (get) => get(sessionFilterAtom).hideSessionsWithoutUserMessages, - (get, set, newValue: boolean) => { - const currentFilters = get(sessionFilterAtom); - set(sessionFilterAtom, { - ...currentFilters, - hideSessionsWithoutUserMessages: newValue, - }); - }, -); diff --git a/src/app/projects/hooks/useProjects.ts b/src/app/projects/hooks/useProjects.ts index a610d22..489a9ff 100644 --- a/src/app/projects/hooks/useProjects.ts +++ b/src/app/projects/hooks/useProjects.ts @@ -1,13 +1,18 @@ 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; + export const useProjects = () => { return useSuspenseQuery({ - queryKey: ["projects"], - queryFn: async () => { - const response = await honoClient.api.projects.$get(); - const { projects } = await response.json(); - return projects; - }, + queryKey: projetsQueryConfig.queryKey, + queryFn: projetsQueryConfig.queryFn, }); }; diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index f3aff8e..9696619 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -1,10 +1,19 @@ +import { QueryClient } from "@tanstack/react-query"; import { HistoryIcon } from "lucide-react"; import { ProjectList } from "./components/ProjectList"; +import { projetsQueryConfig } from "./hooks/useProjects"; export const dynamic = "force-dynamic"; export const fetchCache = "force-no-store"; export default async function ProjectsPage() { + const queryClient = new QueryClient(); + + await queryClient.prefetchQuery({ + queryKey: projetsQueryConfig.queryKey, + queryFn: projetsQueryConfig.queryFn, + }); + return (
diff --git a/src/hooks/useServerEvents.ts b/src/hooks/useServerEvents.ts index 8d9cb74..ea62627 100644 --- a/src/hooks/useServerEvents.ts +++ b/src/hooks/useServerEvents.ts @@ -1,5 +1,6 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect } from "react"; +import { projetsQueryConfig } from "../app/projects/hooks/useProjects"; import { honoClient } from "../lib/api/client"; import type { SSEEvent } from "../server/service/events/types"; @@ -77,12 +78,12 @@ export const useServerEvents = () => { console.log("data", event); if (event.data.type === "project_changed") { - console.log("invalidating projects"); - await queryClient.invalidateQueries({ queryKey: ["projects"] }); + await queryClient.invalidateQueries({ + queryKey: projetsQueryConfig.queryKey, + }); } if (event.data.type === "session_changed") { - console.log("invalidating sessions"); await queryClient.invalidateQueries({ queryKey: ["sessions"] }); } } diff --git a/src/lib/api/QueryClientProviderWrapper.tsx b/src/lib/api/QueryClientProviderWrapper.tsx index 92bf942..7176030 100644 --- a/src/lib/api/QueryClientProviderWrapper.tsx +++ b/src/lib/api/QueryClientProviderWrapper.tsx @@ -1,12 +1,39 @@ "use client"; -import { QueryClientProvider } from "@tanstack/react-query"; +import { + isServer, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import type { FC, PropsWithChildren } from "react"; -import { queryClient } from "./queryClient"; + +let browserQueryClient: QueryClient | undefined; + +export const getQueryClient = () => { + if (isServer) { + return makeQueryClient(); + } else { + browserQueryClient ??= makeQueryClient(); + return browserQueryClient; + } +}; + +export const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: true, + refetchInterval: 1000 * 60 * 5, + retry: false, + }, + }, + }); export const QueryClientProviderWrapper: FC = ({ children, }) => { + const queryClient = getQueryClient(); + return ( {children} ); diff --git a/src/lib/api/queryClient.ts b/src/lib/api/queryClient.ts deleted file mode 100644 index ac4dcf2..0000000 --- a/src/lib/api/queryClient.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { QueryClient } from "@tanstack/react-query"; - -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - refetchOnWindowFocus: true, - refetchInterval: 1000 * 60 * 5, - retry: false, - }, - }, -}); diff --git a/src/server/config/config.ts b/src/server/config/config.ts new file mode 100644 index 0000000..1c74bc2 --- /dev/null +++ b/src/server/config/config.ts @@ -0,0 +1,7 @@ +import z from "zod"; + +export const configSchema = z.object({ + hideNoUserMessageSession: z.boolean().optional().default(true), +}); + +export type Config = z.infer; diff --git a/src/server/hono/app.ts b/src/server/hono/app.ts index a7155eb..4534757 100644 --- a/src/server/hono/app.ts +++ b/src/server/hono/app.ts @@ -1,7 +1,11 @@ import { Hono } from "hono"; +import type { Config } from "../config/config"; -// biome-ignore lint/complexity/noBannedTypes: add after -export type HonoContext = {}; +export type HonoContext = { + Variables: { + config: Config; + }; +}; export const honoApp = new Hono().basePath("/api"); diff --git a/src/server/hono/middleware/config.middleware.ts b/src/server/hono/middleware/config.middleware.ts new file mode 100644 index 0000000..067984a --- /dev/null +++ b/src/server/hono/middleware/config.middleware.ts @@ -0,0 +1,31 @@ +import { getCookie, setCookie } from "hono/cookie"; +import { createMiddleware } from "hono/factory"; +import { configSchema } from "../../config/config"; +import type { HonoContext } from "../app"; + +export const configMiddleware = createMiddleware( + async (c, next) => { + const cookie = getCookie(c, "ccv-config"); + const parsed = (() => { + try { + return configSchema.parse(JSON.parse(cookie ?? "{}")); + } catch { + return configSchema.parse({}); + } + })(); + + if (cookie === undefined) { + setCookie( + c, + "ccv-config", + JSON.stringify({ + hideNoUserMessageSession: true, + }), + ); + } + + c.set("config", parsed); + + await next(); + }, +); diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 5623daf..195f5d5 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -2,8 +2,10 @@ import { readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { zValidator } from "@hono/zod-validator"; +import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { z } from "zod"; +import { configSchema } from "../config/config"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import { getFileWatcher } from "../service/events/fileWatcher"; import { sseEvent } from "../service/events/sseEvent"; @@ -13,240 +15,271 @@ import { getProjects } from "../service/project/getProjects"; import { getSession } from "../service/session/getSession"; import { getSessions } from "../service/session/getSessions"; import type { HonoAppType } from "./app"; +import { configMiddleware } from "./middleware/config.middleware"; export const routes = (app: HonoAppType) => { const taskController = new ClaudeCodeTaskController(); - return app - .get("/projects", async (c) => { - const { projects } = await getProjects(); - return c.json({ projects }); - }) + return ( + app + // middleware + .use(configMiddleware) - .get("/projects/:projectId", async (c) => { - const { projectId } = c.req.param(); + // routes + .get("/config", async (c) => { + return c.json({ + config: c.get("config"), + }); + }) - const [{ project }, { sessions }] = await Promise.all([ - getProject(projectId), - getSessions(projectId), - ] as const); + .put("/config", zValidator("json", configSchema), async (c) => { + const { ...config } = c.req.valid("json"); - return c.json({ project, sessions }); - }) + setCookie(c, "ccv-config", JSON.stringify(config)); - .get("/projects/:projectId/sessions/:sessionId", async (c) => { - const { projectId, sessionId } = c.req.param(); - const { session } = await getSession(projectId, sessionId); - return c.json({ session }); - }) + return c.json({ + config, + }); + }) - .get("/projects/:projectId/claude-commands", async (c) => { - const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + .get("/projects", async (c) => { + const { projects } = await getProjects(); + return c.json({ projects }); + }) - const [globalCommands, projectCommands] = await Promise.allSettled([ - readdir(resolve(homedir(), ".claude", "commands"), { - withFileTypes: true, - }).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ), - project.meta.projectPath !== null - ? readdir(resolve(project.meta.projectPath, ".claude", "commands"), { - withFileTypes: true, - }).then((dirents) => - dirents - .filter((d) => d.isFile() && d.name.endsWith(".md")) - .map((d) => d.name.replace(/\.md$/, "")), - ) - : [], - ]); - - return c.json({ - globalCommands: - globalCommands.status === "fulfilled" ? globalCommands.value : [], - projectCommands: - projectCommands.status === "fulfilled" ? projectCommands.value : [], - }); - }) - - .post( - "/projects/:projectId/new-session", - zValidator( - "json", - z.object({ - message: z.string(), - }), - ), - async (c) => { + .get("/projects/:projectId", async (c) => { const { projectId } = c.req.param(); - const { message } = c.req.valid("json"); - const { project } = await getProject(projectId); - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } + const [{ project }, { sessions }] = await Promise.all([ + getProject(projectId), + getSessions(projectId).then(({ sessions }) => ({ + sessions: sessions.filter((session) => { + if (c.get("config").hideNoUserMessageSession) { + return session.meta.firstCommand !== null; + } + return true; + }), + })), + ] as const); - const task = await taskController.createTask({ - projectId, - cwd: project.meta.projectPath, - message, - }); + return c.json({ project, sessions }); + }) - const { nextSessionId, userMessageId } = await taskController.startTask( - task.id, - ); - return c.json({ taskId: task.id, nextSessionId, userMessageId }); - }, - ) - - .post( - "/projects/:projectId/sessions/:sessionId/resume", - zValidator( - "json", - z.object({ - resumeMessage: z.string(), - }), - ), - async (c) => { + .get("/projects/:projectId/sessions/:sessionId", async (c) => { const { projectId, sessionId } = c.req.param(); - const { resumeMessage } = c.req.valid("json"); + const { session } = await getSession(projectId, sessionId); + return c.json({ session }); + }) + + .get("/projects/:projectId/claude-commands", async (c) => { + const { projectId } = c.req.param(); const { project } = await getProject(projectId); - if (project.meta.projectPath === null) { - return c.json({ error: "Project path not found" }, 400); - } + const [globalCommands, projectCommands] = await Promise.allSettled([ + readdir(resolve(homedir(), ".claude", "commands"), { + withFileTypes: true, + }).then((dirents) => + dirents + .filter((d) => d.isFile() && d.name.endsWith(".md")) + .map((d) => d.name.replace(/\.md$/, "")), + ), + project.meta.projectPath !== null + ? readdir( + resolve(project.meta.projectPath, ".claude", "commands"), + { + withFileTypes: true, + }, + ).then((dirents) => + dirents + .filter((d) => d.isFile() && d.name.endsWith(".md")) + .map((d) => d.name.replace(/\.md$/, "")), + ) + : [], + ]); - const task = await taskController.createTask({ - projectId, - sessionId, - cwd: project.meta.projectPath, - message: resumeMessage, + return c.json({ + globalCommands: + globalCommands.status === "fulfilled" ? globalCommands.value : [], + projectCommands: + projectCommands.status === "fulfilled" ? projectCommands.value : [], }); + }) - const { nextSessionId, userMessageId } = await taskController.startTask( - task.id, - ); - return c.json({ taskId: task.id, nextSessionId, userMessageId }); - }, - ) + .post( + "/projects/:projectId/new-session", + zValidator( + "json", + z.object({ + message: z.string(), + }), + ), + async (c) => { + const { projectId } = c.req.param(); + const { message } = c.req.valid("json"); + const { project } = await getProject(projectId); - .get("/tasks/running", async (c) => { - return c.json({ runningTasks: taskController.runningTasks }); - }) + if (project.meta.projectPath === null) { + return c.json({ error: "Project path not found" }, 400); + } - .get("/events/state_changes", async (c) => { - return streamSSE( - c, - async (stream) => { - const fileWatcher = getFileWatcher(); - let isConnected = true; - let eventId = 0; + const task = await taskController.createTask({ + projectId, + cwd: project.meta.projectPath, + message, + }); - // ハートビート設定 - const heartbeat = setInterval(() => { - if (isConnected) { - stream + const { nextSessionId, userMessageId } = + await taskController.startTask(task.id); + return c.json({ taskId: task.id, nextSessionId, userMessageId }); + }, + ) + + .post( + "/projects/:projectId/sessions/:sessionId/resume", + zValidator( + "json", + z.object({ + resumeMessage: z.string(), + }), + ), + async (c) => { + const { projectId, sessionId } = c.req.param(); + const { resumeMessage } = c.req.valid("json"); + const { project } = await getProject(projectId); + + if (project.meta.projectPath === null) { + return c.json({ error: "Project path not found" }, 400); + } + + const task = await taskController.createTask({ + projectId, + sessionId, + cwd: project.meta.projectPath, + message: resumeMessage, + }); + + const { nextSessionId, userMessageId } = + await taskController.startTask(task.id); + return c.json({ taskId: task.id, nextSessionId, userMessageId }); + }, + ) + + .get("/tasks/running", async (c) => { + return c.json({ runningTasks: taskController.runningTasks }); + }) + + .get("/events/state_changes", async (c) => { + return streamSSE( + c, + async (stream) => { + const fileWatcher = getFileWatcher(); + let isConnected = true; + let eventId = 0; + + // ハートビート設定 + const heartbeat = setInterval(() => { + if (isConnected) { + stream + .writeSSE({ + data: sseEvent({ + type: "heartbeat", + timestamp: new Date().toISOString(), + }), + event: "heartbeat", + id: String(eventId++), + }) + .catch(() => { + console.warn("Failed to write SSE event"); + isConnected = false; + onConnectionClosed(); + }); + } + }, 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); + }; + + // 接続終了時のクリーンアップ + stream.onAbort(() => { + console.log("SSE connection aborted"); + onConnectionClosed(); + }); + + // イベントリスナーを登録 + console.log("Registering SSE event listeners"); + fileWatcher.on("project_changed", async (event: WatcherEvent) => { + if (!isConnected) { + return; + } + + if (event.eventType !== "project_changed") { + return; + } + + await stream .writeSSE({ data: sseEvent({ - type: "heartbeat", - timestamp: new Date().toISOString(), + type: event.eventType, + ...event.data, }), - event: "heartbeat", + event: event.eventType, id: String(eventId++), }) .catch(() => { console.warn("Failed to write SSE event"); - isConnected = false; onConnectionClosed(); }); - } - }, 30 * 1000); + }); + fileWatcher.on("session_changed", async (event: WatcherEvent) => { + if (!isConnected) { + return; + } - // connection handling - const abortController = new AbortController(); - let connectionResolve: ((value: undefined) => void) | undefined; - const connectionPromise = new Promise((resolve) => { - connectionResolve = resolve; - }); + await stream + .writeSSE({ + data: sseEvent({ + ...event.data, + type: event.eventType, + }), + event: event.eventType, + id: String(eventId++), + }) + .catch(() => { + onConnectionClosed(); + }); + }); - const onConnectionClosed = () => { - isConnected = false; - connectionResolve?.(undefined); - abortController.abort(); - clearInterval(heartbeat); - }; + // 初期接続確認メッセージ + await stream.writeSSE({ + data: sseEvent({ + type: "connected", + message: "SSE connection established", + timestamp: new Date().toISOString(), + }), + event: "connected", + id: String(eventId++), + }); - // 接続終了時のクリーンアップ - stream.onAbort(() => { - console.log("SSE connection aborted"); - onConnectionClosed(); - }); - - // イベントリスナーを登録 - console.log("Registering SSE event listeners"); - fileWatcher.on("project_changed", async (event: WatcherEvent) => { - if (!isConnected) { - return; - } - - if (event.eventType !== "project_changed") { - return; - } - - await stream - .writeSSE({ - data: sseEvent({ - type: event.eventType, - ...event.data, - }), - event: event.eventType, - id: String(eventId++), - }) - .catch(() => { - console.warn("Failed to write SSE event"); - onConnectionClosed(); - }); - }); - fileWatcher.on("session_changed", async (event: WatcherEvent) => { - if (!isConnected) { - return; - } - - await stream - .writeSSE({ - data: sseEvent({ - ...event.data, - type: event.eventType, - }), - event: event.eventType, - id: String(eventId++), - }) - .catch(() => { - onConnectionClosed(); - }); - }); - - // 初期接続確認メッセージ - await stream.writeSSE({ - data: sseEvent({ - type: "connected", - message: "SSE connection established", - timestamp: new Date().toISOString(), - }), - event: "connected", - id: String(eventId++), - }); - - await connectionPromise; - }, - async (err, stream) => { - console.error("Streaming error:", err); - await stream.write("エラーが発生しました。"); - }, - ); - }); + await connectionPromise; + }, + async (err, stream) => { + console.error("Streaming error:", err); + await stream.write("エラーが発生しました。"); + }, + ); + }) + ); }; export type RouteType = ReturnType;