From d322db543c6ef7e014598806ea24f09860f628d7 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Wed, 15 Oct 2025 02:25:26 +0900 Subject: [PATCH] perf: added pagination for session in order to improve large project's performance issue --- .../[projectId]/components/ProjectPage.tsx | 37 +++- .../projects/[projectId]/hooks/useProject.ts | 12 +- src/app/projects/[projectId]/page.tsx | 9 +- .../components/SessionPageContent.tsx | 9 +- .../sessionSidebar/MobileSidebar.tsx | 14 +- .../sessionSidebar/SessionSidebar.tsx | 14 +- .../components/sessionSidebar/SessionsTab.tsx | 39 +++- src/app/projects/components/ProjectList.tsx | 4 +- src/lib/api/queries.ts | 7 +- src/server/hono/initialize.ts | 20 +- src/server/hono/route.ts | 176 +++++++++--------- .../service/claude-code/ClaudeCodeExecutor.ts | 8 +- .../claude-code/ClaudeCodeTaskController.ts | 28 +-- .../service/project/ProjectRepository.ts | 12 +- .../service/project/projectMetaStorage.ts | 5 - src/server/service/schema.ts | 2 - .../session/PredictSessionsDatabase.ts | 8 +- .../service/session/SessionRepository.ts | 95 +++++++--- .../service/session/sessionMetaStorage.ts | 7 - src/server/service/types.ts | 2 + 20 files changed, 316 insertions(+), 192 deletions(-) diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx index 53a90de..417f0de 100644 --- a/src/app/projects/[projectId]/components/ProjectPage.tsx +++ b/src/app/projects/[projectId]/components/ProjectPage.tsx @@ -25,24 +25,30 @@ import { CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; -import { projectDetailQuery } from "../../../../lib/api/queries"; import { useConfig } from "../../../hooks/useConfig"; import { useProject } from "../hooks/useProject"; import { firstCommandToTitle } from "../services/firstCommandToTitle"; import { NewChatModal } from "./newChat/NewChatModal"; export const ProjectPageContent = ({ projectId }: { projectId: string }) => { - const { - data: { project, sessions }, - } = useProject(projectId); + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useProject(projectId); const { config } = useConfig(); const queryClient = useQueryClient(); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + // Flatten all pages to get project and sessions + const project = data.pages.at(0)?.project; + const sessions = data.pages.flatMap((page) => page.sessions); + + if (!project) { + throw new Error("Unreachable: Project must be defined."); + } + // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed useEffect(() => { void queryClient.invalidateQueries({ - queryKey: projectDetailQuery(projectId).queryKey, + queryKey: ["projects", projectId], }); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]); @@ -170,10 +176,8 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {

Last modified:{" "} - {session.meta.lastModifiedAt - ? new Date( - session.meta.lastModifiedAt, - ).toLocaleDateString() + {session.lastModifiedAt + ? new Date(session.lastModifiedAt).toLocaleDateString() : ""}

@@ -195,6 +199,21 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { ))} )} + + {/* Load More Button */} + {sessions.length > 0 && hasNextPage && ( +

+ +
+ )} diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts index 7ea840d..9036b75 100644 --- a/src/app/projects/[projectId]/hooks/useProject.ts +++ b/src/app/projects/[projectId]/hooks/useProject.ts @@ -1,10 +1,14 @@ -import { useSuspenseQuery } from "@tanstack/react-query"; +import { useSuspenseInfiniteQuery } from "@tanstack/react-query"; import { projectDetailQuery } from "../../../../lib/api/queries"; export const useProject = (projectId: string) => { - return useSuspenseQuery({ - queryKey: projectDetailQuery(projectId).queryKey, - queryFn: projectDetailQuery(projectId).queryFn, + return useSuspenseInfiniteQuery({ + queryKey: ["projects", projectId], + queryFn: async ({ pageParam }) => { + return await projectDetailQuery(projectId, pageParam).queryFn(); + }, + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor, refetchOnReconnect: true, }); }; diff --git a/src/app/projects/[projectId]/page.tsx b/src/app/projects/[projectId]/page.tsx index ec66435..d8b841b 100644 --- a/src/app/projects/[projectId]/page.tsx +++ b/src/app/projects/[projectId]/page.tsx @@ -15,9 +15,12 @@ export default async function ProjectPage({ params }: ProjectPageProps) { const queryClient = new QueryClient(); - await queryClient.prefetchQuery({ - queryKey: projectDetailQuery(projectId).queryKey, - queryFn: projectDetailQuery(projectId).queryFn, + await queryClient.prefetchInfiniteQuery({ + queryKey: ["projects", projectId], + queryFn: async ({ pageParam }) => { + return await projectDetailQuery(projectId, pageParam).queryFn(); + }, + initialPageParam: undefined as string | undefined, }); return ( diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx index c55d0ae..3949211 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent.tsx @@ -35,7 +35,9 @@ export const SessionPageContent: FC<{ projectId, sessionId, ); - const { data: project } = useProject(projectId); + const { data: projectData } = useProject(projectId); + // biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page + const project = projectData.pages[0]!.project; const abortTask = useMutation({ mutationFn: async (sessionId: string) => { @@ -111,7 +113,7 @@ export const SessionPageContent: FC<{
- {project?.project.claudeProjectPath && ( + {project?.claudeProjectPath && ( - {project.project.meta.projectPath ?? - project.project.claudeProjectPath} + {project.meta.projectPath ?? project.claudeProjectPath} )} diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx index 2900418..d49066f 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx @@ -24,8 +24,12 @@ export const MobileSidebar: FC = ({ onClose, }) => { const { - data: { sessions }, + data: projectData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = useProject(projectId); + const sessions = projectData.pages.flatMap((page) => page.sessions); const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">( "sessions", ); @@ -71,9 +75,15 @@ export const MobileSidebar: FC = ({ case "sessions": return ( ({ + ...session, + lastModifiedAt: new Date(session.lastModifiedAt), + }))} currentSessionId={currentSessionId} projectId={projectId} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + onLoadMore={() => fetchNextPage()} /> ); case "mcp": diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index cb926a5..5b4a9f0 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -30,8 +30,12 @@ export const SessionSidebar: FC<{ }) => { const router = useRouter(); const { - data: { sessions }, + data: projectData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, } = useProject(projectId); + const sessions = projectData.pages.flatMap((page) => page.sessions); const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">( "sessions", ); @@ -53,9 +57,15 @@ export const SessionSidebar: FC<{ case "sessions": return ( ({ + ...session, + lastModifiedAt: new Date(session.lastModifiedAt), + }))} currentSessionId={currentSessionId} projectId={projectId} + hasNextPage={hasNextPage} + isFetchingNextPage={isFetchingNextPage} + onLoadMore={() => fetchNextPage()} /> ); case "mcp": diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx index 635ba32..221090b 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionsTab.tsx @@ -16,7 +16,17 @@ export const SessionsTab: FC<{ sessions: Session[]; currentSessionId: string; projectId: string; -}> = ({ sessions, currentSessionId, projectId }) => { + hasNextPage?: boolean; + isFetchingNextPage?: boolean; + onLoadMore?: () => void; +}> = ({ + sessions, + currentSessionId, + projectId, + hasNextPage, + isFetchingNextPage, + onLoadMore, +}) => { const aliveTasks = useAtomValue(aliveTasksAtom); // Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first) @@ -43,12 +53,8 @@ export const SessionsTab: FC<{ } // Then sort by lastModifiedAt (newest first) - const aTime = a.meta.lastModifiedAt - ? new Date(a.meta.lastModifiedAt).getTime() - : 0; - const bTime = b.meta.lastModifiedAt - ? new Date(b.meta.lastModifiedAt).getTime() - : 0; + const aTime = a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0; + const bTime = b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0; return bTime - aTime; }); @@ -121,9 +127,9 @@ export const SessionsTab: FC<{ {session.meta.messageCount}
- {session.meta.lastModifiedAt && ( + {session.lastModifiedAt && ( - {new Date(session.meta.lastModifiedAt).toLocaleDateString( + {new Date(session.lastModifiedAt).toLocaleDateString( "en-US", { month: "short", @@ -137,6 +143,21 @@ export const SessionsTab: FC<{ ); })} + + {/* Load More Button */} + {hasNextPage && onLoadMore && ( +
+ +
+ )} ); diff --git a/src/app/projects/components/ProjectList.tsx b/src/app/projects/components/ProjectList.tsx index 98e5038..eb4fa1b 100644 --- a/src/app/projects/components/ProjectList.tsx +++ b/src/app/projects/components/ProjectList.tsx @@ -49,8 +49,8 @@ export const ProjectList: FC = () => {

Last modified:{" "} - {project.meta.lastModifiedAt - ? new Date(project.meta.lastModifiedAt).toLocaleDateString() + {project.lastModifiedAt + ? new Date(project.lastModifiedAt).toLocaleDateString() : ""}

diff --git a/src/lib/api/queries.ts b/src/lib/api/queries.ts index 297921a..3ee0d2a 100644 --- a/src/lib/api/queries.ts +++ b/src/lib/api/queries.ts @@ -16,12 +16,15 @@ export const projectListQuery = { }, } as const; -export const projectDetailQuery = (projectId: string) => +export const projectDetailQuery = (projectId: string, cursor?: string) => ({ - queryKey: ["projects", projectId], + queryKey: cursor + ? ["projects", projectId, cursor] + : ["projects", projectId], queryFn: async () => { const response = await honoClient.api.projects[":projectId"].$get({ param: { projectId }, + query: { cursor }, }); if (!response.ok) { diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts index 2b32e02..5e992b2 100644 --- a/src/server/hono/initialize.ts +++ b/src/server/hono/initialize.ts @@ -1,5 +1,8 @@ +import prexit from "prexit"; +import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import { eventBus } from "../service/events/EventBus"; import { fileWatcher } from "../service/events/fileWatcher"; +import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import type { ProjectRepository } from "../service/project/ProjectRepository"; import { projectMetaStorage } from "../service/project/projectMetaStorage"; import type { SessionRepository } from "../service/session/SessionRepository"; @@ -11,14 +14,18 @@ export const initialize = async (deps: { }): Promise => { fileWatcher.startWatching(); - setInterval(() => { + const intervalId = setInterval(() => { eventBus.emit("heartbeat", {}); }, 10 * 1000); - eventBus.on("sessionChanged", (event) => { + const onSessionChanged = ( + event: InternalEventDeclaration["sessionChanged"], + ) => { projectMetaStorage.invalidateProject(event.projectId); sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); - }); + }; + + eventBus.on("sessionChanged", onSessionChanged); try { console.log("Initializing projects cache"); @@ -38,4 +45,11 @@ export const initialize = async (deps: { } catch { // do nothing } + + prexit(() => { + clearInterval(intervalId); + eventBus.off("sessionChanged", onSessionChanged); + fileWatcher.stop(); + claudeCodeTaskController.abortAllTasks(); + }); }; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index c437935..bec1951 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -4,9 +4,9 @@ import { zValidator } from "@hono/zod-validator"; import { setCookie } from "hono/cookie"; import { streamSSE } from "hono/streaming"; import { z } from "zod"; -import { type Config, configSchema } from "../config/config"; +import { configSchema } from "../config/config"; import { env } from "../lib/env"; -import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; +import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { eventBus } from "../service/events/EventBus"; @@ -25,16 +25,6 @@ import { initialize } from "./initialize"; import { configMiddleware } from "./middleware/config.middleware"; export const routes = async (app: HonoAppType) => { - let taskController: ClaudeCodeTaskController | null = null; - const getTaskController = (config: Config) => { - if (!taskController) { - taskController = new ClaudeCodeTaskController(config); - } else { - taskController.updateConfig(config); - } - return taskController; - }; - const sessionRepository = new SessionRepository(); const projectRepository = new ProjectRepository(); @@ -49,6 +39,10 @@ export const routes = async (app: HonoAppType) => { app // middleware .use(configMiddleware) + .use(async (c, next) => { + claudeCodeTaskController.updateConfig(c.get("config")); + await next(); + }) // routes .get("/config", async (c) => { @@ -72,85 +66,93 @@ export const routes = async (app: HonoAppType) => { return c.json({ projects }); }) - .get("/projects/:projectId", async (c) => { - const { projectId } = c.req.param(); + .get( + "/projects/:projectId", + zValidator("query", z.object({ cursor: z.string().optional() })), + async (c) => { + const { projectId } = c.req.param(); + const { cursor } = c.req.valid("query"); - const [{ project }, { sessions }] = await Promise.all([ - projectRepository.getProject(projectId), - sessionRepository.getSessions(projectId).then(({ sessions }) => { - let filteredSessions = sessions; + const [{ project }, { sessions, nextCursor }] = await Promise.all([ + projectRepository.getProject(projectId), + sessionRepository + .getSessions(projectId, { cursor }) + .then(({ sessions }) => { + let filteredSessions = sessions; - // Filter sessions based on hideNoUserMessageSession setting - if (c.get("config").hideNoUserMessageSession) { - filteredSessions = filteredSessions.filter((session) => { - return session.meta.firstCommand !== null; - }); - } + // Filter sessions based on hideNoUserMessageSession setting + if (c.get("config").hideNoUserMessageSession) { + filteredSessions = filteredSessions.filter((session) => { + return session.meta.firstCommand !== null; + }); + } - // Unify sessions with same title if unifySameTitleSession is enabled - if (c.get("config").unifySameTitleSession) { - const sessionMap = new Map< - string, - (typeof filteredSessions)[0] - >(); + // Unify sessions with same title if unifySameTitleSession is enabled + if (c.get("config").unifySameTitleSession) { + const sessionMap = new Map< + string, + (typeof filteredSessions)[0] + >(); - for (const session of filteredSessions) { - // Generate title for comparison - const title = - session.meta.firstCommand !== null - ? (() => { - const cmd = session.meta.firstCommand; - switch (cmd.kind) { - case "command": - return cmd.commandArgs === undefined - ? cmd.commandName - : `${cmd.commandName} ${cmd.commandArgs}`; - case "local-command": - return cmd.stdout; - case "text": - return cmd.content; - default: - return session.id; + for (const session of filteredSessions) { + // Generate title for comparison + const title = + session.meta.firstCommand !== null + ? (() => { + const cmd = session.meta.firstCommand; + switch (cmd.kind) { + case "command": + return cmd.commandArgs === undefined + ? cmd.commandName + : `${cmd.commandName} ${cmd.commandArgs}`; + case "local-command": + return cmd.stdout; + case "text": + return cmd.content; + default: + return session.id; + } + })() + : session.id; + + const existingSession = sessionMap.get(title); + if (existingSession) { + // Keep the session with the latest modification date + if ( + session.lastModifiedAt && + existingSession.lastModifiedAt + ) { + if ( + session.lastModifiedAt > + existingSession.lastModifiedAt + ) { + sessionMap.set(title, session); } - })() - : session.id; - - const existingSession = sessionMap.get(title); - if (existingSession) { - // Keep the session with the latest modification date - if ( - session.meta.lastModifiedAt && - existingSession.meta.lastModifiedAt - ) { - if ( - new Date(session.meta.lastModifiedAt) > - new Date(existingSession.meta.lastModifiedAt) - ) { + } else if ( + session.lastModifiedAt && + !existingSession.lastModifiedAt + ) { + sessionMap.set(title, session); + } + // If no modification dates, keep the existing one + } else { sessionMap.set(title, session); } - } else if ( - session.meta.lastModifiedAt && - !existingSession.meta.lastModifiedAt - ) { - sessionMap.set(title, session); } - // If no modification dates, keep the existing one - } else { - sessionMap.set(title, session); + + filteredSessions = Array.from(sessionMap.values()); } - } - filteredSessions = Array.from(sessionMap.values()); - } + return { + sessions: filteredSessions, + nextCursor: sessions.at(-1)?.id, + }; + }), + ] as const); - return { - sessions: filteredSessions, - }; - }), - ] as const); - - return c.json({ project, sessions }); - }) + return c.json({ project, sessions, nextCursor }); + }, + ) .get("/projects/:projectId/sessions/:sessionId", async (c) => { const { projectId, sessionId } = c.req.param(); @@ -324,9 +326,7 @@ export const routes = async (app: HonoAppType) => { return c.json({ error: "Project path not found" }, 400); } - const task = await getTaskController( - c.get("config"), - ).startOrContinueTask( + const task = await claudeCodeTaskController.startOrContinueTask( { projectId, cwd: project.meta.projectPath, @@ -358,9 +358,7 @@ export const routes = async (app: HonoAppType) => { return c.json({ error: "Project path not found" }, 400); } - const task = await getTaskController( - c.get("config"), - ).startOrContinueTask( + const task = await claudeCodeTaskController.startOrContinueTask( { projectId, sessionId, @@ -378,7 +376,7 @@ export const routes = async (app: HonoAppType) => { .get("/tasks/alive", async (c) => { return c.json({ - aliveTasks: getTaskController(c.get("config")).aliveTasks.map( + aliveTasks: claudeCodeTaskController.aliveTasks.map( (task): SerializableAliveTask => ({ id: task.id, status: task.status, @@ -393,7 +391,7 @@ export const routes = async (app: HonoAppType) => { zValidator("json", z.object({ sessionId: z.string() })), async (c) => { const { sessionId } = c.req.valid("json"); - getTaskController(c.get("config")).abortTask(sessionId); + claudeCodeTaskController.abortTask(sessionId); return c.json({ message: "Task aborted" }); }, ) @@ -409,7 +407,7 @@ export const routes = async (app: HonoAppType) => { ), async (c) => { const permissionResponse = c.req.valid("json"); - getTaskController(c.get("config")).respondToPermissionRequest( + claudeCodeTaskController.respondToPermissionRequest( permissionResponse, ); return c.json({ message: "Permission response received" }); diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index b9de1b2..55285b3 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -41,14 +41,18 @@ export class ClaudeCodeExecutor { } public query(prompt: CCQueryPrompt, options: CCQueryOptions) { - const { canUseTool, ...baseOptions } = options; + const { canUseTool, permissionMode, ...baseOptions } = options; return query({ prompt, options: { pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, ...baseOptions, - ...(this.availableFeatures.canUseTool ? { canUseTool } : {}), + ...(this.availableFeatures.canUseTool + ? { canUseTool, permissionMode } + : { + permissionMode: "bypassPermissions", + }), }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index f7ad96c..cb630a7 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,4 +1,3 @@ -import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; import { eventBus } from "../events/EventBus"; @@ -14,22 +13,21 @@ import type { RunningClaudeCodeTask, } from "./types"; -export class ClaudeCodeTaskController { +class ClaudeCodeTaskController { private claudeCode: ClaudeCodeExecutor; private tasks: ClaudeCodeTask[] = []; private config: Config; private pendingPermissionRequests: Map = new Map(); private permissionResponses: Map = new Map(); - constructor(config: Config) { + constructor() { this.claudeCode = new ClaudeCodeExecutor(); - this.config = config; - - prexit(() => { - this.aliveTasks.forEach((task) => { - task.abortController.abort(); - }); - }); + this.config = { + hideNoUserMessageSession: false, + unifySameTitleSession: false, + enterKeyBehavior: "shift-enter-send", + permissionMode: "default", + }; } public updateConfig(config: Config) { @@ -292,9 +290,9 @@ export class ClaudeCodeTaskController { ], meta: { firstCommand: null, - lastModifiedAt: new Date().toISOString(), messageCount: 0, }, + lastModifiedAt: new Date(), }); } @@ -407,6 +405,12 @@ export class ClaudeCodeTaskController { }); } + public abortAllTasks() { + for (const task of this.aliveTasks) { + task.abortController.abort(); + } + } + private upsertExistingTask(task: ClaudeCodeTask) { const target = this.tasks.find((t) => t.id === task.id); @@ -425,3 +429,5 @@ export class ClaudeCodeTaskController { } } } + +export const claudeCodeTaskController = new ClaudeCodeTaskController(); diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts index d6d7055..df87935 100644 --- a/src/server/service/project/ProjectRepository.ts +++ b/src/server/service/project/ProjectRepository.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { access, constants, readdir } from "node:fs/promises"; import { resolve } from "node:path"; import { claudeProjectsDirPath } from "../paths"; @@ -19,6 +19,7 @@ export class ProjectRepository { project: { id: projectId, claudeProjectPath: fullPath, + lastModifiedAt: statSync(fullPath).mtime, meta, }, }; @@ -50,6 +51,7 @@ export class ProjectRepository { return { id, claudeProjectPath: fullPath, + lastModifiedAt: statSync(fullPath).mtime, meta: await projectMetaStorage.getProjectMeta(id), }; }), @@ -58,12 +60,8 @@ export class ProjectRepository { return { projects: projects.sort((a, b) => { return ( - (b.meta.lastModifiedAt - ? new Date(b.meta.lastModifiedAt).getTime() - : 0) - - (a.meta.lastModifiedAt - ? new Date(a.meta.lastModifiedAt).getTime() - : 0) + (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - + (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) ); }), }; diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts index e12814c..85bd698 100644 --- a/src/server/service/project/projectMetaStorage.ts +++ b/src/server/service/project/projectMetaStorage.ts @@ -37,8 +37,6 @@ class ProjectMetaStorage { return a.stats.mtime.getTime() - b.stats.mtime.getTime(); }); - const lastModifiedUnixTime = files.at(-1)?.stats.mtime.getTime(); - let projectPath: string | null = null; for (const file of files) { @@ -54,9 +52,6 @@ class ProjectMetaStorage { const projectMeta: ProjectMeta = { projectName: projectPath ? basename(projectPath) : null, projectPath, - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime).toISOString() - : null, sessionCount: files.length, }; diff --git a/src/server/service/schema.ts b/src/server/service/schema.ts index 3060cf5..ef2fd37 100644 --- a/src/server/service/schema.ts +++ b/src/server/service/schema.ts @@ -4,12 +4,10 @@ import { parsedCommandSchema } from "./parseCommandXml"; export const projectMetaSchema = z.object({ projectName: z.string().nullable(), projectPath: z.string().nullable(), - lastModifiedAt: z.string().nullable(), sessionCount: z.number(), }); export const sessionMetaSchema = z.object({ messageCount: z.number(), firstCommand: parsedCommandSchema.nullable(), - lastModifiedAt: z.string().nullable(), }); diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts index 161b273..8dbca71 100644 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -14,12 +14,8 @@ class PredictSessionsDatabase { ); } - public getPredictSession(sessionId: string): SessionDetail { - const session = this.storage.get(sessionId); - if (!session) { - throw new Error("Session not found"); - } - return session; + public getPredictSession(sessionId: string): SessionDetail | null { + return this.storage.get(sessionId) ?? null; } public createPredictSession(session: SessionDetail) { diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 2fc9c28..7374cfd 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,4 +1,4 @@ -import { existsSync } from "node:fs"; +import { existsSync, statSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { parseJsonl } from "../parseJsonl"; @@ -8,11 +8,6 @@ import { decodeSessionId, encodeSessionId } from "./id"; import { predictSessionsDatabase } from "./PredictSessionsDatabase"; import { sessionMetaStorage } from "./sessionMetaStorage"; -const getTime = (date: string | null) => { - if (date === null) return 0; - return new Date(date).getTime(); -}; - export class SessionRepository { public async getSession( projectId: string, @@ -33,14 +28,16 @@ export class SessionRepository { throw new Error("Session not found"); } const content = await readFile(sessionPath, "utf-8"); + const allLines = content.split("\n").filter((line) => line.trim()); - const conversations = parseJsonl(content); + const conversations = parseJsonl(allLines.join("\n")); const sessionDetail: SessionDetail = { id: sessionId, jsonlFilePath: sessionPath, meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), conversations, + lastModifiedAt: statSync(sessionPath).mtime, }; return { @@ -50,36 +47,88 @@ export class SessionRepository { public async getSessions( projectId: string, + options?: { + maxCount?: number; + cursor?: string; + }, ): Promise<{ sessions: Session[] }> { + const { maxCount = 20, cursor } = options ?? {}; + try { const claudeProjectPath = decodeProjectId(projectId); const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); const sessions = await Promise.all( dirents .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map(async (d) => ({ - id: encodeSessionId(resolve(claudeProjectPath, d.name)), - jsonlFilePath: resolve(claudeProjectPath, d.name), - meta: await sessionMetaStorage.getSessionMeta( - projectId, - encodeSessionId(resolve(claudeProjectPath, d.name)), - ), - })), + .map(async (d) => { + const sessionId = encodeSessionId( + resolve(claudeProjectPath, d.name), + ); + const stats = statSync(resolve(claudeProjectPath, d.name)); + + return { + id: sessionId, + jsonlFilePath: resolve(claudeProjectPath, d.name), + lastModifiedAt: stats.mtime, + }; + }), + ).then((fetched) => + fetched.sort( + (a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(), + ), ); - const sessionMap = new Map( - sessions.map((session) => [session.id, session]), + + const sessionMap = new Map( + sessions.map((session) => [session.id, session] as const), ); + const index = + cursor !== undefined + ? sessions.findIndex((session) => session.id === cursor) + : -1; + + if (index !== -1) { + return { + sessions: await Promise.all( + sessions + .slice(index + 1, Math.min(index + 1 + maxCount, sessions.length)) + .map(async (item) => { + return { + ...item, + meta: await sessionMetaStorage.getSessionMeta( + projectId, + item.id, + ), + }; + }), + ), + }; + } + const predictSessions = predictSessionsDatabase .getPredictSessions(projectId) - .filter((session) => !sessionMap.has(session.id)); + .filter((session) => !sessionMap.has(session.id)) + .sort((a, b) => { + return b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(); + }); return { - sessions: [...predictSessions, ...sessions].sort((a, b) => { - return ( - getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) - ); - }), + sessions: [ + ...predictSessions, + ...(await Promise.all( + sessions + .slice(0, Math.min(maxCount, sessions.length)) + .map(async (item) => { + return { + ...item, + meta: await sessionMetaStorage.getSessionMeta( + projectId, + item.id, + ), + }; + }), + )), + ], }; } catch (error) { console.warn(`Failed to read sessions for project ${projectId}:`, error); diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts index 975db76..00bbb47 100644 --- a/src/server/service/session/sessionMetaStorage.ts +++ b/src/server/service/session/sessionMetaStorage.ts @@ -1,4 +1,3 @@ -import { statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; @@ -38,18 +37,12 @@ class SessionMetaStorage { const sessionPath = decodeSessionId(projectId, sessionId); - const stats = statSync(sessionPath); - const lastModifiedUnixTime = stats.mtime.getTime(); - const content = await readFile(sessionPath, "utf-8"); const lines = content.split("\n"); const sessionMeta: SessionMeta = { messageCount: lines.length, firstCommand: this.getFirstCommand(sessionPath, lines), - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime).toISOString() - : null, }; this.sessionMetaCache.save(sessionId, sessionMeta); diff --git a/src/server/service/types.ts b/src/server/service/types.ts index b5ca5d9..2668e30 100644 --- a/src/server/service/types.ts +++ b/src/server/service/types.ts @@ -5,6 +5,7 @@ import type { projectMetaSchema, sessionMetaSchema } from "./schema"; export type Project = { id: string; claudeProjectPath: string; + lastModifiedAt: Date; meta: ProjectMeta; }; @@ -13,6 +14,7 @@ export type ProjectMeta = z.infer; export type Session = { id: string; jsonlFilePath: string; + lastModifiedAt: Date; meta: SessionMeta; };