refactor: add effect-ts and refactor codes

This commit is contained in:
d-kimsuon
2025-10-15 23:22:27 +09:00
parent 94cc1c0630
commit 21070d09ff
76 changed files with 7598 additions and 1950 deletions

View File

@@ -1,8 +1,53 @@
import { NodeContext } from "@effect/platform-node";
import { Effect } from "effect";
import { handle } from "hono/vercel";
import { honoApp } from "../../../server/hono/app";
import { InitializeService } from "../../../server/hono/initialize";
import { routes } from "../../../server/hono/route";
import { ClaudeCodeLifeCycleService } from "../../../server/service/claude-code/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "../../../server/service/claude-code/ClaudeCodePermissionService";
import { ClaudeCodeSessionProcessService } from "../../../server/service/claude-code/ClaudeCodeSessionProcessService";
import { EventBus } from "../../../server/service/events/EventBus";
import { FileWatcherService } from "../../../server/service/events/fileWatcher";
import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService";
import { ProjectRepository } from "../../../server/service/project/ProjectRepository";
import { VirtualConversationDatabase } from "../../../server/service/session/PredictSessionsDatabase";
import { SessionMetaService } from "../../../server/service/session/SessionMetaService";
import { SessionRepository } from "../../../server/service/session/SessionRepository";
await routes(honoApp);
const program = routes(honoApp);
await Effect.runPromise(
program.pipe(
// 依存の浅い順にコンテナに pipe する必要がある
/** Application */
Effect.provide(InitializeService.Live),
/** Domain */
Effect.provide(ClaudeCodeLifeCycleService.Live),
Effect.provide(ClaudeCodePermissionService.Live),
Effect.provide(ClaudeCodeSessionProcessService.Live),
// Shared Services
Effect.provide(FileWatcherService.Live),
Effect.provide(EventBus.Live),
/** Infrastructure */
// Repository
Effect.provide(ProjectRepository.Live),
Effect.provide(SessionRepository.Live),
// StorageService
Effect.provide(ProjectMetaService.Live),
Effect.provide(SessionMetaService.Live),
Effect.provide(VirtualConversationDatabase.Live),
/** Platform */
Effect.provide(NodeContext.layer),
),
);
export const GET = handle(honoApp);
export const POST = handle(honoApp);

View File

@@ -5,11 +5,11 @@ 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";
import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
const queryClient = useQueryClient();
const setAliveTasks = useSetAtom(aliveTasksAtom);
const setSessionProcesses = useSetAtom(sessionProcessesAtom);
useServerEventListener("sessionListChanged", async (event) => {
// invalidate session list
@@ -25,8 +25,8 @@ export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
});
});
useServerEventListener("taskChanged", async ({ aliveTasks }) => {
setAliveTasks(aliveTasks);
useServerEventListener("sessionProcessChanged", async ({ processes }) => {
setSessionProcesses(processes);
});
return <>{children}</>;

View File

@@ -0,0 +1,18 @@
"use client";
import { useSetAtom } from "jotai";
import { type FC, type PropsWithChildren, useEffect } from "react";
import type { PublicSessionProcess } from "../../types/session-process";
import { sessionProcessesAtom } from "../projects/[projectId]/sessions/[sessionId]/store/sessionProcessesAtom";
export const SyncSessionProcess: FC<
PropsWithChildren<{ initProcesses: PublicSessionProcess[] }>
> = ({ children, initProcesses }) => {
const setSessionProcesses = useSetAtom(sessionProcessesAtom);
useEffect(() => {
setSessionProcesses(initProcesses);
}, [initProcesses, setSessionProcesses]);
return <>{children}</>;
};

View File

@@ -7,8 +7,10 @@ import { SSEProvider } from "../lib/sse/components/SSEProvider";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import "./globals.css";
import { honoClient } from "../lib/api/client";
import { configQuery } from "../lib/api/queries";
import { SSEEventListeners } from "./components/SSEEventListeners";
import { SyncSessionProcess } from "./components/SyncSessionProcess";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
@@ -40,6 +42,10 @@ export default async function RootLayout({
queryFn: configQuery.queryFn,
});
const initSessionProcesses = await honoClient.api.cc["session-processes"]
.$get({})
.then((response) => response.json());
return (
<html lang="en">
<body
@@ -48,7 +54,13 @@ export default async function RootLayout({
<RootErrorBoundary>
<QueryClientProviderWrapper>
<SSEProvider>
<SSEEventListeners>{children}</SSEEventListeners>
<SSEEventListeners>
<SyncSessionProcess
initProcesses={initSessionProcesses.processes}
>
{children}
</SyncSessionProcess>
</SSEEventListeners>
</SSEProvider>
</QueryClientProviderWrapper>
</RootErrorBoundary>

View File

@@ -4,4 +4,7 @@ export type { CommandCompletionRef } from "./CommandCompletion";
export { CommandCompletion } from "./CommandCompletion";
export type { FileCompletionRef } from "./FileCompletion";
export { FileCompletion } from "./FileCompletion";
export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";
export {
useContinueSessionProcessMutation,
useCreateSessionProcessMutation,
} from "./useChatMutations";

View File

@@ -2,20 +2,24 @@ import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { honoClient } from "../../../../../lib/api/client";
export const useNewChatMutation = (
export const useCreateSessionProcessMutation = (
projectId: string,
onSuccess?: () => void,
) => {
const router = useRouter();
return useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"][
"new-session"
].$post(
mutationFn: async (options: {
message: string;
baseSessionId?: string;
}) => {
const response = await honoClient.api.cc["session-processes"].$post(
{
param: { projectId },
json: { message: options.message },
json: {
projectId,
baseSessionId: options.baseSessionId,
message: options.message,
},
},
{
init: {
@@ -32,22 +36,32 @@ export const useNewChatMutation = (
},
onSuccess: async (response) => {
onSuccess?.();
router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
router.push(
`/projects/${projectId}/sessions/${response.sessionProcess.sessionId}`,
);
},
});
};
export const useResumeChatMutation = (projectId: string, sessionId: string) => {
const router = useRouter();
export const useContinueSessionProcessMutation = (
projectId: string,
baseSessionId: string,
) => {
return useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].resume.$post(
mutationFn: async (options: {
message: string;
sessionProcessId: string;
}) => {
const response = await honoClient.api.cc["session-processes"][
":sessionProcessId"
].continue.$post(
{
param: { projectId, sessionId },
json: { resumeMessage: options.message },
param: { sessionProcessId: options.sessionProcessId },
json: {
projectId: projectId,
baseSessionId: baseSessionId,
continueMessage: options.message,
},
},
{
init: {
@@ -62,10 +76,5 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => {
return response.json();
},
onSuccess: async (response) => {
if (sessionId !== response.sessionId) {
router.push(`/projects/${projectId}/sessions/${response.sessionId}`);
}
},
});
};

View File

@@ -1,16 +1,19 @@
import type { FC } from "react";
import { useConfig } from "../../../../hooks/useConfig";
import { ChatInput, useNewChatMutation } from "../chatForm";
import { ChatInput, useCreateSessionProcessMutation } from "../chatForm";
export const NewChat: FC<{
projectId: string;
onSuccess?: () => void;
}> = ({ projectId, onSuccess }) => {
const startNewChat = useNewChatMutation(projectId, onSuccess);
const createSessionProcess = useCreateSessionProcessMutation(
projectId,
onSuccess,
);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
await startNewChat.mutateAsync({ message });
await createSessionProcess.mutateAsync({ message });
};
const getPlaceholder = () => {
@@ -25,8 +28,8 @@ export const NewChat: FC<{
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={startNewChat.isPending}
error={startNewChat.error}
isPending={createSessionProcess.isPending}
error={createSessionProcess.error}
placeholder={getPlaceholder()}
buttonText="Start Chat"
minHeight="min-h-[200px]"

View File

@@ -11,7 +11,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
import { Button } from "@/components/ui/button";
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
@@ -20,10 +20,11 @@ import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { useAliveTask } from "../hooks/useAliveTask";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
import { DiffModal } from "./diffModal";
import { ContinueChat } from "./resumeChat/ContinueChat";
import { ResumeChat } from "./resumeChat/ResumeChat";
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
@@ -40,9 +41,12 @@ export const SessionPageContent: FC<{
const project = projectData.pages[0]!.project;
const abortTask = useMutation({
mutationFn: async (sessionId: string) => {
const response = await honoClient.api.tasks.abort.$post({
json: { sessionId },
mutationFn: async (sessionProcessId: string) => {
const response = await honoClient.api.cc["session-processes"][
":sessionProcessId"
].abort.$post({
param: { sessionProcessId },
json: { projectId },
});
if (!response.ok) {
@@ -52,13 +56,18 @@ export const SessionPageContent: FC<{
return response.json();
},
});
const sessionProcess = useSessionProcess();
const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
const relatedSessionProcess = useMemo(
() => sessionProcess.getSessionProcess(sessionId),
[sessionProcess, sessionId],
);
// Set up task completion notifications
useTaskNotifications(isRunningTask);
useTaskNotifications(relatedSessionProcess?.status === "running");
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
@@ -69,7 +78,7 @@ export const SessionPageContent: FC<{
// 自動スクロール処理
useEffect(() => {
if (
(isRunningTask || isPausedTask) &&
relatedSessionProcess?.status === "running" &&
conversations.length !== previousConversationLength
) {
setPreviousConversationLength(conversations.length);
@@ -81,7 +90,11 @@ export const SessionPageContent: FC<{
});
}
}
}, [conversations, isRunningTask, isPausedTask, previousConversationLength]);
}, [
conversations,
relatedSessionProcess?.status,
previousConversationLength,
]);
return (
<div className="flex h-screen max-h-screen overflow-hidden">
@@ -136,7 +149,7 @@ export const SessionPageContent: FC<{
</Badge>
</div>
{isRunningTask && (
{relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
<div className="flex-1">
@@ -148,7 +161,7 @@ export const SessionPageContent: FC<{
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(sessionId);
abortTask.mutate(relatedSessionProcess.id);
}}
>
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
@@ -157,7 +170,7 @@ export const SessionPageContent: FC<{
</div>
)}
{isPausedTask && (
{relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" />
<div className="flex-1">
@@ -169,7 +182,7 @@ export const SessionPageContent: FC<{
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(sessionId);
abortTask.mutate(relatedSessionProcess.id);
}}
>
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
@@ -190,7 +203,7 @@ export const SessionPageContent: FC<{
getToolResult={getToolResult}
/>
{isRunningTask && (
{relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8">
<div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2">
@@ -207,12 +220,15 @@ export const SessionPageContent: FC<{
</div>
)}
<ResumeChat
projectId={projectId}
sessionId={sessionId}
isPausedTask={isPausedTask}
isRunningTask={isRunningTask}
/>
{relatedSessionProcess !== undefined ? (
<ContinueChat
projectId={projectId}
sessionId={sessionId}
sessionProcessId={relatedSessionProcess.id}
/>
) : (
<ResumeChat projectId={projectId} sessionId={sessionId} />
)}
</main>
</div>
</div>

View File

@@ -0,0 +1,46 @@
import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
ChatInput,
useContinueSessionProcessMutation,
} from "../../../../components/chatForm";
export const ContinueChat: FC<{
projectId: string;
sessionId: string;
sessionProcessId: string;
}> = ({ projectId, sessionId, sessionProcessId }) => {
const continueSessionProcess = useContinueSessionProcessMutation(
projectId,
sessionId,
);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
await continueSessionProcess.mutateAsync({ message, sessionProcessId });
};
const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send";
if (isEnterSend) {
return "Type your message... (Start with / for commands, Enter to send)";
}
return "Type your message... (Start with / for commands, Shift+Enter to send)";
};
return (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={continueSessionProcess.isPending}
error={continueSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={"Send"}
minHeight="min-h-[100px]"
containerClassName="space-y-2"
buttonSize="default"
/>
</div>
);
};

View File

@@ -2,27 +2,21 @@ import type { FC } from "react";
import { useConfig } from "../../../../../../hooks/useConfig";
import {
ChatInput,
useResumeChatMutation,
useCreateSessionProcessMutation,
} from "../../../../components/chatForm";
export const ResumeChat: FC<{
projectId: string;
sessionId: string;
isPausedTask: boolean;
isRunningTask: boolean;
}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
const resumeChat = useResumeChatMutation(projectId, sessionId);
}> = ({ projectId, sessionId }) => {
const createSessionProcess = useCreateSessionProcessMutation(projectId);
const { config } = useConfig();
const handleSubmit = async (message: string) => {
await resumeChat.mutateAsync({ message });
};
const getButtonText = () => {
if (isPausedTask || isRunningTask) {
return "Send";
}
return "Resume";
await createSessionProcess.mutateAsync({
message,
baseSessionId: sessionId,
});
};
const getPlaceholder = () => {
@@ -38,10 +32,10 @@ export const ResumeChat: FC<{
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={resumeChat.isPending}
error={resumeChat.error}
isPending={createSessionProcess.isPending}
error={createSessionProcess.error}
placeholder={getPlaceholder()}
buttonText={getButtonText()}
buttonText={"Resume"}
minHeight="min-h-[100px]"
containerClassName="space-y-2"
buttonSize="default"

View File

@@ -6,7 +6,7 @@ import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { mcpListQuery } from "../../../../../../../lib/api/queries";
export const McpTab: FC = () => {
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
const queryClient = useQueryClient();
const {
@@ -14,12 +14,14 @@ export const McpTab: FC = () => {
isLoading,
error,
} = useQuery({
queryKey: mcpListQuery.queryKey,
queryFn: mcpListQuery.queryFn,
queryKey: mcpListQuery(projectId).queryKey,
queryFn: mcpListQuery(projectId).queryFn,
});
const handleReload = () => {
queryClient.invalidateQueries({ queryKey: mcpListQuery.queryKey });
queryClient.invalidateQueries({
queryKey: mcpListQuery(projectId).queryKey,
});
};
return (

View File

@@ -87,7 +87,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
/>
);
case "mcp":
return <McpTab />;
return <McpTab projectId={projectId} />;
case "settings":
return <SettingsTab openingProjectId={projectId} />;
default:

View File

@@ -69,7 +69,7 @@ export const SessionSidebar: FC<{
/>
);
case "mcp":
return <McpTab />;
return <McpTab projectId={projectId} />;
case "settings":
return <SettingsTab openingProjectId={projectId} />;
default:

View File

@@ -10,7 +10,7 @@ import { cn } from "@/lib/utils";
import type { Session } from "../../../../../../../server/service/types";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { firstCommandToTitle } from "../../../../services/firstCommandToTitle";
import { aliveTasksAtom } from "../../store/aliveTasksAtom";
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
export const SessionsTab: FC<{
sessions: Session[];
@@ -27,18 +27,22 @@ export const SessionsTab: FC<{
isFetchingNextPage,
onLoadMore,
}) => {
const aliveTasks = useAtomValue(aliveTasksAtom);
const sessionProcesses = useAtomValue(sessionProcessesAtom);
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
const sortedSessions = [...sessions].sort((a, b) => {
const aTask = aliveTasks.find((task) => task.sessionId === a.id);
const bTask = aliveTasks.find((task) => task.sessionId === b.id);
const aProcess = sessionProcesses.find(
(process) => process.sessionId === a.id,
);
const bProcess = sessionProcesses.find(
(process) => process.sessionId === b.id,
);
const aStatus = aTask?.status;
const bStatus = bTask?.status;
const aStatus = aProcess?.status;
const bStatus = bProcess?.status;
// Define priority: running = 0, paused = 1, others = 2
const getPriority = (status: string | undefined) => {
const getPriority = (status: "paused" | "running" | undefined) => {
if (status === "running") return 0;
if (status === "paused") return 1;
return 2;
@@ -86,11 +90,11 @@ export const SessionsTab: FC<{
? firstCommandToTitle(session.meta.firstCommand)
: session.id;
const aliveTask = aliveTasks.find(
const sessionProcess = sessionProcesses.find(
(task) => task.sessionId === session.id,
);
const isRunning = aliveTask?.status === "running";
const isPaused = aliveTask?.status === "paused";
const isRunning = sessionProcess?.status === "running";
const isPaused = sessionProcess?.status === "paused";
return (
<Link

View File

@@ -1,31 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useMemo } from "react";
import { aliveTasksQuery } from "../../../../../../lib/api/queries";
import { aliveTasksAtom } from "../store/aliveTasksAtom";
export const useAliveTask = (sessionId: string) => {
const [aliveTasks, setAliveTasks] = useAtom(aliveTasksAtom);
useQuery({
queryKey: aliveTasksQuery.queryKey,
queryFn: async () => {
const { aliveTasks } = await aliveTasksQuery.queryFn();
setAliveTasks(aliveTasks);
return aliveTasks;
},
refetchOnReconnect: true,
});
const taskInfo = useMemo(() => {
const aliveTask = aliveTasks.find((task) => task.sessionId === sessionId);
return {
aliveTask: aliveTasks.find((task) => task.sessionId === sessionId),
isRunningTask: aliveTask?.status === "running",
isPausedTask: aliveTask?.status === "paused",
} as const;
}, [aliveTasks, sessionId]);
return taskInfo;
};

View File

@@ -3,9 +3,13 @@ import { useSessionQuery } from "./useSessionQuery";
export const useSession = (projectId: string, sessionId: string) => {
const query = useSessionQuery(projectId, sessionId);
const session = query.data?.session;
if (session === undefined || session === null) {
throw new Error("Session not found");
}
const toolResultMap = useMemo(() => {
const entries = query.data.session.conversations.flatMap((conversation) => {
const entries = session.conversations.flatMap((conversation) => {
if (conversation.type !== "user") {
return [];
}
@@ -28,7 +32,7 @@ export const useSession = (projectId: string, sessionId: string) => {
});
return new Map(entries);
}, [query.data.session.conversations]);
}, [session.conversations]);
const getToolResult = useCallback(
(toolUseId: string) => {
@@ -38,8 +42,8 @@ export const useSession = (projectId: string, sessionId: string) => {
);
return {
session: query.data.session,
conversations: query.data.session.conversations,
session,
conversations: session.conversations,
getToolResult,
};
};

View File

@@ -0,0 +1,23 @@
import { useAtomValue } from "jotai";
import { useCallback } from "react";
import { sessionProcessesAtom } from "../store/sessionProcessesAtom";
export const useSessionProcess = () => {
const sessionProcesses = useAtomValue(sessionProcessesAtom);
const getSessionProcess = useCallback(
(sessionId: string) => {
const targetProcess = sessionProcesses.find(
(process) => process.sessionId === sessionId,
);
return targetProcess;
},
[sessionProcesses],
);
return {
sessionProcesses,
getSessionProcess,
};
};

View File

@@ -1,4 +0,0 @@
import { atom } from "jotai";
import type { SerializableAliveTask } from "../../../../../../server/service/claude-code/types";
export const aliveTasksAtom = atom<SerializableAliveTask[]>([]);

View File

@@ -0,0 +1,4 @@
import { atom } from "jotai";
import type { PublicSessionProcess } from "../../../../../../types/session-process";
export const sessionProcessesAtom = atom<PublicSessionProcess[]>([]);