mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-25 17:24:21 +01:00
feat: move configuration localStorage to server side
This commit is contained in:
@@ -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",
|
||||
|
||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
45
src/app/hooks/useConfig.ts
Normal file
45
src/app/hooks/useConfig.ts
Normal file
@@ -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;
|
||||
};
|
||||
@@ -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 (
|
||||
<html lang="ja">
|
||||
<body
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useAtom } from "jotai";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
FolderIcon,
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PlusIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useId } from "react";
|
||||
import { useEffect, useId } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
@@ -18,9 +18,9 @@ import {
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useProject } from "../hooks/useProject";
|
||||
import { configQueryConfig, useConfig } from "../../../hooks/useConfig";
|
||||
import { projectQueryConfig, useProject } from "../hooks/useProject";
|
||||
import { firstCommandToTitle } from "../services/firstCommandToTitle";
|
||||
import { hideSessionsWithoutUserMessagesAtom } from "../store/filterAtoms";
|
||||
import { NewChatModal } from "./newChat/NewChatModal";
|
||||
|
||||
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
@@ -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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -72,13 +74,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">
|
||||
Conversation Sessions{" "}
|
||||
{filteredSessions.length > 0 ? `(${filteredSessions.length})` : ""}
|
||||
{hideSessionsWithoutUserMessages &&
|
||||
filteredSessions.length !== sessions.length && (
|
||||
<span className="text-sm text-muted-foreground ml-2">
|
||||
of {sessions.length} total
|
||||
</span>
|
||||
)}
|
||||
{sessions.length > 0 ? `(${sessions.length})` : ""}
|
||||
</h2>
|
||||
|
||||
{/* Filter Controls */}
|
||||
@@ -86,8 +82,16 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={checkboxId}
|
||||
checked={hideSessionsWithoutUserMessages}
|
||||
onCheckedChange={setHideSessionsWithoutUserMessages}
|
||||
checked={config?.hideNoUserMessageSession}
|
||||
onCheckedChange={async () => {
|
||||
updateConfig({
|
||||
...config,
|
||||
hideNoUserMessageSession: !config?.hideNoUserMessageSession,
|
||||
});
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: configQueryConfig.queryKey,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<label
|
||||
htmlFor={checkboxId}
|
||||
@@ -101,7 +105,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{filteredSessions.length === 0 ? (
|
||||
{sessions.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-12">
|
||||
<MessageSquareIcon className="w-12 h-12 text-muted-foreground mb-4" />
|
||||
@@ -124,7 +128,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
|
||||
{filteredSessions.map((session) => (
|
||||
{sessions.map((session) => (
|
||||
<Card
|
||||
key={session.id}
|
||||
className="hover:shadow-md transition-shadow"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||
import { honoClient } from "../../../../lib/api/client";
|
||||
|
||||
export const useProject = (projectId: string) => {
|
||||
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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 <ProjectPageContent projectId={projectId} />;
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
await queryClient.prefetchQuery({
|
||||
...projectQueryConfig(projectId),
|
||||
});
|
||||
|
||||
return (
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<ProjectPageContent projectId={projectId} />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<PageParams>;
|
||||
}): Promise<Metadata> {
|
||||
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}`,
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { atom } from "jotai";
|
||||
import { atomWithStorage } from "jotai/utils";
|
||||
|
||||
export type SessionFilterOptions = {
|
||||
hideSessionsWithoutUserMessages: boolean;
|
||||
};
|
||||
|
||||
export const sessionFilterAtom = atomWithStorage<SessionFilterOptions>(
|
||||
"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,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<header className="mb-8">
|
||||
|
||||
@@ -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"] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PropsWithChildren> = ({
|
||||
children,
|
||||
}) => {
|
||||
const queryClient = getQueryClient();
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
7
src/server/config/config.ts
Normal file
7
src/server/config/config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import z from "zod";
|
||||
|
||||
export const configSchema = z.object({
|
||||
hideNoUserMessageSession: z.boolean().optional().default(true),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof configSchema>;
|
||||
@@ -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<HonoContext>().basePath("/api");
|
||||
|
||||
|
||||
31
src/server/hono/middleware/config.middleware.ts
Normal file
31
src/server/hono/middleware/config.middleware.ts
Normal file
@@ -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<HonoContext>(
|
||||
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();
|
||||
},
|
||||
);
|
||||
@@ -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<undefined>((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<undefined>((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<typeof routes>;
|
||||
|
||||
Reference in New Issue
Block a user