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;