perf: added pagination for session in order to improve large project's performance issue

This commit is contained in:
d-kimsuon
2025-10-15 02:25:26 +09:00
parent 0259e71b44
commit d322db543c
20 changed files with 316 additions and 192 deletions

View File

@@ -25,24 +25,30 @@ import {
CollapsibleContent, CollapsibleContent,
CollapsibleTrigger, CollapsibleTrigger,
} from "@/components/ui/collapsible"; } from "@/components/ui/collapsible";
import { projectDetailQuery } from "../../../../lib/api/queries";
import { useConfig } from "../../../hooks/useConfig"; import { useConfig } from "../../../hooks/useConfig";
import { useProject } from "../hooks/useProject"; import { useProject } from "../hooks/useProject";
import { firstCommandToTitle } from "../services/firstCommandToTitle"; import { firstCommandToTitle } from "../services/firstCommandToTitle";
import { NewChatModal } from "./newChat/NewChatModal"; import { NewChatModal } from "./newChat/NewChatModal";
export const ProjectPageContent = ({ projectId }: { projectId: string }) => { export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
const { const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
data: { project, sessions }, useProject(projectId);
} = useProject(projectId);
const { config } = useConfig(); const { config } = useConfig();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [isSettingsOpen, setIsSettingsOpen] = useState(false); 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 // biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
useEffect(() => { useEffect(() => {
void queryClient.invalidateQueries({ void queryClient.invalidateQueries({
queryKey: projectDetailQuery(projectId).queryKey, queryKey: ["projects", projectId],
}); });
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
@@ -170,10 +176,8 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Last modified:{" "} Last modified:{" "}
{session.meta.lastModifiedAt {session.lastModifiedAt
? new Date( ? new Date(session.lastModifiedAt).toLocaleDateString()
session.meta.lastModifiedAt,
).toLocaleDateString()
: ""} : ""}
</p> </p>
<p className="text-xs text-muted-foreground font-mono"> <p className="text-xs text-muted-foreground font-mono">
@@ -195,6 +199,21 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
))} ))}
</div> </div>
)} )}
{/* Load More Button */}
{sessions.length > 0 && hasNextPage && (
<div className="mt-6 flex justify-center">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="lg"
className="min-w-[200px]"
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</Button>
</div>
)}
</section> </section>
</main> </main>
</div> </div>

View File

@@ -1,10 +1,14 @@
import { useSuspenseQuery } from "@tanstack/react-query"; import { useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { projectDetailQuery } from "../../../../lib/api/queries"; import { projectDetailQuery } from "../../../../lib/api/queries";
export const useProject = (projectId: string) => { export const useProject = (projectId: string) => {
return useSuspenseQuery({ return useSuspenseInfiniteQuery({
queryKey: projectDetailQuery(projectId).queryKey, queryKey: ["projects", projectId],
queryFn: projectDetailQuery(projectId).queryFn, queryFn: async ({ pageParam }) => {
return await projectDetailQuery(projectId, pageParam).queryFn();
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.nextCursor,
refetchOnReconnect: true, refetchOnReconnect: true,
}); });
}; };

View File

@@ -15,9 +15,12 @@ export default async function ProjectPage({ params }: ProjectPageProps) {
const queryClient = new QueryClient(); const queryClient = new QueryClient();
await queryClient.prefetchQuery({ await queryClient.prefetchInfiniteQuery({
queryKey: projectDetailQuery(projectId).queryKey, queryKey: ["projects", projectId],
queryFn: projectDetailQuery(projectId).queryFn, queryFn: async ({ pageParam }) => {
return await projectDetailQuery(projectId, pageParam).queryFn();
},
initialPageParam: undefined as string | undefined,
}); });
return ( return (

View File

@@ -35,7 +35,9 @@ export const SessionPageContent: FC<{
projectId, projectId,
sessionId, 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({ const abortTask = useMutation({
mutationFn: async (sessionId: string) => { mutationFn: async (sessionId: string) => {
@@ -111,7 +113,7 @@ export const SessionPageContent: FC<{
</div> </div>
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2"> <div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.project.claudeProjectPath && ( {project?.claudeProjectPath && (
<Link <Link
href={`/projects/${projectId}`} href={`/projects/${projectId}`}
target="_blank" target="_blank"
@@ -122,8 +124,7 @@ export const SessionPageContent: FC<{
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer" className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer"
> >
<ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" /> <ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
{project.project.meta.projectPath ?? {project.meta.projectPath ?? project.claudeProjectPath}
project.project.claudeProjectPath}
</Badge> </Badge>
</Link> </Link>
)} )}

View File

@@ -24,8 +24,12 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
onClose, onClose,
}) => { }) => {
const { const {
data: { sessions }, data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId); } = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">( const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">(
"sessions", "sessions",
); );
@@ -71,9 +75,15 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
case "sessions": case "sessions":
return ( return (
<SessionsTab <SessionsTab
sessions={sessions} sessions={sessions.map((session) => ({
...session,
lastModifiedAt: new Date(session.lastModifiedAt),
}))}
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
projectId={projectId} projectId={projectId}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/> />
); );
case "mcp": case "mcp":

View File

@@ -30,8 +30,12 @@ export const SessionSidebar: FC<{
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { const {
data: { sessions }, data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId); } = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">( const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">(
"sessions", "sessions",
); );
@@ -53,9 +57,15 @@ export const SessionSidebar: FC<{
case "sessions": case "sessions":
return ( return (
<SessionsTab <SessionsTab
sessions={sessions} sessions={sessions.map((session) => ({
...session,
lastModifiedAt: new Date(session.lastModifiedAt),
}))}
currentSessionId={currentSessionId} currentSessionId={currentSessionId}
projectId={projectId} projectId={projectId}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/> />
); );
case "mcp": case "mcp":

View File

@@ -16,7 +16,17 @@ export const SessionsTab: FC<{
sessions: Session[]; sessions: Session[];
currentSessionId: string; currentSessionId: string;
projectId: string; projectId: string;
}> = ({ sessions, currentSessionId, projectId }) => { hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onLoadMore?: () => void;
}> = ({
sessions,
currentSessionId,
projectId,
hasNextPage,
isFetchingNextPage,
onLoadMore,
}) => {
const aliveTasks = useAtomValue(aliveTasksAtom); const aliveTasks = useAtomValue(aliveTasksAtom);
// Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first) // Sort sessions: Running > Paused > Others, then by lastModifiedAt (newest first)
@@ -43,12 +53,8 @@ export const SessionsTab: FC<{
} }
// Then sort by lastModifiedAt (newest first) // Then sort by lastModifiedAt (newest first)
const aTime = a.meta.lastModifiedAt const aTime = a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0;
? new Date(a.meta.lastModifiedAt).getTime() const bTime = b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0;
: 0;
const bTime = b.meta.lastModifiedAt
? new Date(b.meta.lastModifiedAt).getTime()
: 0;
return bTime - aTime; return bTime - aTime;
}); });
@@ -121,9 +127,9 @@ export const SessionsTab: FC<{
<MessageSquareIcon className="w-3 h-3" /> <MessageSquareIcon className="w-3 h-3" />
<span>{session.meta.messageCount}</span> <span>{session.meta.messageCount}</span>
</div> </div>
{session.meta.lastModifiedAt && ( {session.lastModifiedAt && (
<span className="text-xs text-sidebar-foreground/60"> <span className="text-xs text-sidebar-foreground/60">
{new Date(session.meta.lastModifiedAt).toLocaleDateString( {new Date(session.lastModifiedAt).toLocaleDateString(
"en-US", "en-US",
{ {
month: "short", month: "short",
@@ -137,6 +143,21 @@ export const SessionsTab: FC<{
</Link> </Link>
); );
})} })}
{/* Load More Button */}
{hasNextPage && onLoadMore && (
<div className="p-2">
<Button
onClick={onLoadMore}
disabled={isFetchingNextPage}
variant="outline"
size="sm"
className="w-full"
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</Button>
</div>
)}
</div> </div>
</div> </div>
); );

View File

@@ -49,8 +49,8 @@ export const ProjectList: FC = () => {
<CardContent className="space-y-2"> <CardContent className="space-y-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Last modified:{" "} Last modified:{" "}
{project.meta.lastModifiedAt {project.lastModifiedAt
? new Date(project.meta.lastModifiedAt).toLocaleDateString() ? new Date(project.lastModifiedAt).toLocaleDateString()
: ""} : ""}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">

View File

@@ -16,12 +16,15 @@ export const projectListQuery = {
}, },
} as const; } 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 () => { queryFn: async () => {
const response = await honoClient.api.projects[":projectId"].$get({ const response = await honoClient.api.projects[":projectId"].$get({
param: { projectId }, param: { projectId },
query: { cursor },
}); });
if (!response.ok) { if (!response.ok) {

View File

@@ -1,5 +1,8 @@
import prexit from "prexit";
import { claudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import { eventBus } from "../service/events/EventBus"; import { eventBus } from "../service/events/EventBus";
import { fileWatcher } from "../service/events/fileWatcher"; import { fileWatcher } from "../service/events/fileWatcher";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
import type { ProjectRepository } from "../service/project/ProjectRepository"; import type { ProjectRepository } from "../service/project/ProjectRepository";
import { projectMetaStorage } from "../service/project/projectMetaStorage"; import { projectMetaStorage } from "../service/project/projectMetaStorage";
import type { SessionRepository } from "../service/session/SessionRepository"; import type { SessionRepository } from "../service/session/SessionRepository";
@@ -11,14 +14,18 @@ export const initialize = async (deps: {
}): Promise<void> => { }): Promise<void> => {
fileWatcher.startWatching(); fileWatcher.startWatching();
setInterval(() => { const intervalId = setInterval(() => {
eventBus.emit("heartbeat", {}); eventBus.emit("heartbeat", {});
}, 10 * 1000); }, 10 * 1000);
eventBus.on("sessionChanged", (event) => { const onSessionChanged = (
event: InternalEventDeclaration["sessionChanged"],
) => {
projectMetaStorage.invalidateProject(event.projectId); projectMetaStorage.invalidateProject(event.projectId);
sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); sessionMetaStorage.invalidateSession(event.projectId, event.sessionId);
}); };
eventBus.on("sessionChanged", onSessionChanged);
try { try {
console.log("Initializing projects cache"); console.log("Initializing projects cache");
@@ -38,4 +45,11 @@ export const initialize = async (deps: {
} catch { } catch {
// do nothing // do nothing
} }
prexit(() => {
clearInterval(intervalId);
eventBus.off("sessionChanged", onSessionChanged);
fileWatcher.stop();
claudeCodeTaskController.abortAllTasks();
});
}; };

View File

@@ -4,9 +4,9 @@ import { zValidator } from "@hono/zod-validator";
import { setCookie } from "hono/cookie"; import { setCookie } from "hono/cookie";
import { streamSSE } from "hono/streaming"; import { streamSSE } from "hono/streaming";
import { z } from "zod"; import { z } from "zod";
import { type Config, configSchema } from "../config/config"; import { configSchema } from "../config/config";
import { env } from "../lib/env"; 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 type { SerializableAliveTask } from "../service/claude-code/types";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { eventBus } from "../service/events/EventBus"; import { eventBus } from "../service/events/EventBus";
@@ -25,16 +25,6 @@ import { initialize } from "./initialize";
import { configMiddleware } from "./middleware/config.middleware"; import { configMiddleware } from "./middleware/config.middleware";
export const routes = async (app: HonoAppType) => { 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 sessionRepository = new SessionRepository();
const projectRepository = new ProjectRepository(); const projectRepository = new ProjectRepository();
@@ -49,6 +39,10 @@ export const routes = async (app: HonoAppType) => {
app app
// middleware // middleware
.use(configMiddleware) .use(configMiddleware)
.use(async (c, next) => {
claudeCodeTaskController.updateConfig(c.get("config"));
await next();
})
// routes // routes
.get("/config", async (c) => { .get("/config", async (c) => {
@@ -72,12 +66,18 @@ export const routes = async (app: HonoAppType) => {
return c.json({ projects }); return c.json({ projects });
}) })
.get("/projects/:projectId", async (c) => { .get(
"/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })),
async (c) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { cursor } = c.req.valid("query");
const [{ project }, { sessions }] = await Promise.all([ const [{ project }, { sessions, nextCursor }] = await Promise.all([
projectRepository.getProject(projectId), projectRepository.getProject(projectId),
sessionRepository.getSessions(projectId).then(({ sessions }) => { sessionRepository
.getSessions(projectId, { cursor })
.then(({ sessions }) => {
let filteredSessions = sessions; let filteredSessions = sessions;
// Filter sessions based on hideNoUserMessageSession setting // Filter sessions based on hideNoUserMessageSession setting
@@ -119,18 +119,18 @@ export const routes = async (app: HonoAppType) => {
if (existingSession) { if (existingSession) {
// Keep the session with the latest modification date // Keep the session with the latest modification date
if ( if (
session.meta.lastModifiedAt && session.lastModifiedAt &&
existingSession.meta.lastModifiedAt existingSession.lastModifiedAt
) { ) {
if ( if (
new Date(session.meta.lastModifiedAt) > session.lastModifiedAt >
new Date(existingSession.meta.lastModifiedAt) existingSession.lastModifiedAt
) { ) {
sessionMap.set(title, session); sessionMap.set(title, session);
} }
} else if ( } else if (
session.meta.lastModifiedAt && session.lastModifiedAt &&
!existingSession.meta.lastModifiedAt !existingSession.lastModifiedAt
) { ) {
sessionMap.set(title, session); sessionMap.set(title, session);
} }
@@ -145,12 +145,14 @@ export const routes = async (app: HonoAppType) => {
return { return {
sessions: filteredSessions, sessions: filteredSessions,
nextCursor: sessions.at(-1)?.id,
}; };
}), }),
] as const); ] as const);
return c.json({ project, sessions }); return c.json({ project, sessions, nextCursor });
}) },
)
.get("/projects/:projectId/sessions/:sessionId", async (c) => { .get("/projects/:projectId/sessions/:sessionId", async (c) => {
const { projectId, sessionId } = c.req.param(); 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); return c.json({ error: "Project path not found" }, 400);
} }
const task = await getTaskController( const task = await claudeCodeTaskController.startOrContinueTask(
c.get("config"),
).startOrContinueTask(
{ {
projectId, projectId,
cwd: project.meta.projectPath, cwd: project.meta.projectPath,
@@ -358,9 +358,7 @@ export const routes = async (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
} }
const task = await getTaskController( const task = await claudeCodeTaskController.startOrContinueTask(
c.get("config"),
).startOrContinueTask(
{ {
projectId, projectId,
sessionId, sessionId,
@@ -378,7 +376,7 @@ export const routes = async (app: HonoAppType) => {
.get("/tasks/alive", async (c) => { .get("/tasks/alive", async (c) => {
return c.json({ return c.json({
aliveTasks: getTaskController(c.get("config")).aliveTasks.map( aliveTasks: claudeCodeTaskController.aliveTasks.map(
(task): SerializableAliveTask => ({ (task): SerializableAliveTask => ({
id: task.id, id: task.id,
status: task.status, status: task.status,
@@ -393,7 +391,7 @@ export const routes = async (app: HonoAppType) => {
zValidator("json", z.object({ sessionId: z.string() })), zValidator("json", z.object({ sessionId: z.string() })),
async (c) => { async (c) => {
const { sessionId } = c.req.valid("json"); const { sessionId } = c.req.valid("json");
getTaskController(c.get("config")).abortTask(sessionId); claudeCodeTaskController.abortTask(sessionId);
return c.json({ message: "Task aborted" }); return c.json({ message: "Task aborted" });
}, },
) )
@@ -409,7 +407,7 @@ export const routes = async (app: HonoAppType) => {
), ),
async (c) => { async (c) => {
const permissionResponse = c.req.valid("json"); const permissionResponse = c.req.valid("json");
getTaskController(c.get("config")).respondToPermissionRequest( claudeCodeTaskController.respondToPermissionRequest(
permissionResponse, permissionResponse,
); );
return c.json({ message: "Permission response received" }); return c.json({ message: "Permission response received" });

View File

@@ -41,14 +41,18 @@ export class ClaudeCodeExecutor {
} }
public query(prompt: CCQueryPrompt, options: CCQueryOptions) { public query(prompt: CCQueryPrompt, options: CCQueryOptions) {
const { canUseTool, ...baseOptions } = options; const { canUseTool, permissionMode, ...baseOptions } = options;
return query({ return query({
prompt, prompt,
options: { options: {
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
...baseOptions, ...baseOptions,
...(this.availableFeatures.canUseTool ? { canUseTool } : {}), ...(this.availableFeatures.canUseTool
? { canUseTool, permissionMode }
: {
permissionMode: "bypassPermissions",
}),
}, },
}); });
} }

View File

@@ -1,4 +1,3 @@
import prexit from "prexit";
import { ulid } from "ulid"; import { ulid } from "ulid";
import type { Config } from "../../config/config"; import type { Config } from "../../config/config";
import { eventBus } from "../events/EventBus"; import { eventBus } from "../events/EventBus";
@@ -14,22 +13,21 @@ import type {
RunningClaudeCodeTask, RunningClaudeCodeTask,
} from "./types"; } from "./types";
export class ClaudeCodeTaskController { class ClaudeCodeTaskController {
private claudeCode: ClaudeCodeExecutor; private claudeCode: ClaudeCodeExecutor;
private tasks: ClaudeCodeTask[] = []; private tasks: ClaudeCodeTask[] = [];
private config: Config; private config: Config;
private pendingPermissionRequests: Map<string, PermissionRequest> = new Map(); private pendingPermissionRequests: Map<string, PermissionRequest> = new Map();
private permissionResponses: Map<string, PermissionResponse> = new Map(); private permissionResponses: Map<string, PermissionResponse> = new Map();
constructor(config: Config) { constructor() {
this.claudeCode = new ClaudeCodeExecutor(); this.claudeCode = new ClaudeCodeExecutor();
this.config = config; this.config = {
hideNoUserMessageSession: false,
prexit(() => { unifySameTitleSession: false,
this.aliveTasks.forEach((task) => { enterKeyBehavior: "shift-enter-send",
task.abortController.abort(); permissionMode: "default",
}); };
});
} }
public updateConfig(config: Config) { public updateConfig(config: Config) {
@@ -292,9 +290,9 @@ export class ClaudeCodeTaskController {
], ],
meta: { meta: {
firstCommand: null, firstCommand: null,
lastModifiedAt: new Date().toISOString(),
messageCount: 0, 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) { private upsertExistingTask(task: ClaudeCodeTask) {
const target = this.tasks.find((t) => t.id === task.id); const target = this.tasks.find((t) => t.id === task.id);
@@ -425,3 +429,5 @@ export class ClaudeCodeTaskController {
} }
} }
} }
export const claudeCodeTaskController = new ClaudeCodeTaskController();

View File

@@ -1,4 +1,4 @@
import { existsSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import { access, constants, readdir } from "node:fs/promises"; import { access, constants, readdir } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { claudeProjectsDirPath } from "../paths"; import { claudeProjectsDirPath } from "../paths";
@@ -19,6 +19,7 @@ export class ProjectRepository {
project: { project: {
id: projectId, id: projectId,
claudeProjectPath: fullPath, claudeProjectPath: fullPath,
lastModifiedAt: statSync(fullPath).mtime,
meta, meta,
}, },
}; };
@@ -50,6 +51,7 @@ export class ProjectRepository {
return { return {
id, id,
claudeProjectPath: fullPath, claudeProjectPath: fullPath,
lastModifiedAt: statSync(fullPath).mtime,
meta: await projectMetaStorage.getProjectMeta(id), meta: await projectMetaStorage.getProjectMeta(id),
}; };
}), }),
@@ -58,12 +60,8 @@ export class ProjectRepository {
return { return {
projects: projects.sort((a, b) => { projects: projects.sort((a, b) => {
return ( return (
(b.meta.lastModifiedAt (b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) -
? new Date(b.meta.lastModifiedAt).getTime() (a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0)
: 0) -
(a.meta.lastModifiedAt
? new Date(a.meta.lastModifiedAt).getTime()
: 0)
); );
}), }),
}; };

View File

@@ -37,8 +37,6 @@ class ProjectMetaStorage {
return a.stats.mtime.getTime() - b.stats.mtime.getTime(); return a.stats.mtime.getTime() - b.stats.mtime.getTime();
}); });
const lastModifiedUnixTime = files.at(-1)?.stats.mtime.getTime();
let projectPath: string | null = null; let projectPath: string | null = null;
for (const file of files) { for (const file of files) {
@@ -54,9 +52,6 @@ class ProjectMetaStorage {
const projectMeta: ProjectMeta = { const projectMeta: ProjectMeta = {
projectName: projectPath ? basename(projectPath) : null, projectName: projectPath ? basename(projectPath) : null,
projectPath, projectPath,
lastModifiedAt: lastModifiedUnixTime
? new Date(lastModifiedUnixTime).toISOString()
: null,
sessionCount: files.length, sessionCount: files.length,
}; };

View File

@@ -4,12 +4,10 @@ import { parsedCommandSchema } from "./parseCommandXml";
export const projectMetaSchema = z.object({ export const projectMetaSchema = z.object({
projectName: z.string().nullable(), projectName: z.string().nullable(),
projectPath: z.string().nullable(), projectPath: z.string().nullable(),
lastModifiedAt: z.string().nullable(),
sessionCount: z.number(), sessionCount: z.number(),
}); });
export const sessionMetaSchema = z.object({ export const sessionMetaSchema = z.object({
messageCount: z.number(), messageCount: z.number(),
firstCommand: parsedCommandSchema.nullable(), firstCommand: parsedCommandSchema.nullable(),
lastModifiedAt: z.string().nullable(),
}); });

View File

@@ -14,12 +14,8 @@ class PredictSessionsDatabase {
); );
} }
public getPredictSession(sessionId: string): SessionDetail { public getPredictSession(sessionId: string): SessionDetail | null {
const session = this.storage.get(sessionId); return this.storage.get(sessionId) ?? null;
if (!session) {
throw new Error("Session not found");
}
return session;
} }
public createPredictSession(session: SessionDetail) { public createPredictSession(session: SessionDetail) {

View File

@@ -1,4 +1,4 @@
import { existsSync } from "node:fs"; import { existsSync, statSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises"; import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { parseJsonl } from "../parseJsonl"; import { parseJsonl } from "../parseJsonl";
@@ -8,11 +8,6 @@ import { decodeSessionId, encodeSessionId } from "./id";
import { predictSessionsDatabase } from "./PredictSessionsDatabase"; import { predictSessionsDatabase } from "./PredictSessionsDatabase";
import { sessionMetaStorage } from "./sessionMetaStorage"; import { sessionMetaStorage } from "./sessionMetaStorage";
const getTime = (date: string | null) => {
if (date === null) return 0;
return new Date(date).getTime();
};
export class SessionRepository { export class SessionRepository {
public async getSession( public async getSession(
projectId: string, projectId: string,
@@ -33,14 +28,16 @@ export class SessionRepository {
throw new Error("Session not found"); throw new Error("Session not found");
} }
const content = await readFile(sessionPath, "utf-8"); 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 = { const sessionDetail: SessionDetail = {
id: sessionId, id: sessionId,
jsonlFilePath: sessionPath, jsonlFilePath: sessionPath,
meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId),
conversations, conversations,
lastModifiedAt: statSync(sessionPath).mtime,
}; };
return { return {
@@ -50,36 +47,88 @@ export class SessionRepository {
public async getSessions( public async getSessions(
projectId: string, projectId: string,
options?: {
maxCount?: number;
cursor?: string;
},
): Promise<{ sessions: Session[] }> { ): Promise<{ sessions: Session[] }> {
const { maxCount = 20, cursor } = options ?? {};
try { try {
const claudeProjectPath = decodeProjectId(projectId); const claudeProjectPath = decodeProjectId(projectId);
const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); const dirents = await readdir(claudeProjectPath, { withFileTypes: true });
const sessions = await Promise.all( const sessions = await Promise.all(
dirents dirents
.filter((d) => d.isFile() && d.name.endsWith(".jsonl")) .filter((d) => d.isFile() && d.name.endsWith(".jsonl"))
.map(async (d) => ({ .map(async (d) => {
id: encodeSessionId(resolve(claudeProjectPath, d.name)), const sessionId = encodeSessionId(
resolve(claudeProjectPath, d.name),
);
const stats = statSync(resolve(claudeProjectPath, d.name));
return {
id: sessionId,
jsonlFilePath: resolve(claudeProjectPath, d.name), 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] 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( meta: await sessionMetaStorage.getSessionMeta(
projectId, projectId,
encodeSessionId(resolve(claudeProjectPath, d.name)), item.id,
), ),
})), };
); }),
const sessionMap = new Map<string, Session>( ),
sessions.map((session) => [session.id, session]), };
); }
const predictSessions = predictSessionsDatabase const predictSessions = predictSessionsDatabase
.getPredictSessions(projectId) .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 { return {
sessions: [...predictSessions, ...sessions].sort((a, b) => { sessions: [
return ( ...predictSessions,
getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) ...(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) { } catch (error) {
console.warn(`Failed to read sessions for project ${projectId}:`, error); console.warn(`Failed to read sessions for project ${projectId}:`, error);

View File

@@ -1,4 +1,3 @@
import { statSync } from "node:fs";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage";
import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage";
@@ -38,18 +37,12 @@ class SessionMetaStorage {
const sessionPath = decodeSessionId(projectId, sessionId); const sessionPath = decodeSessionId(projectId, sessionId);
const stats = statSync(sessionPath);
const lastModifiedUnixTime = stats.mtime.getTime();
const content = await readFile(sessionPath, "utf-8"); const content = await readFile(sessionPath, "utf-8");
const lines = content.split("\n"); const lines = content.split("\n");
const sessionMeta: SessionMeta = { const sessionMeta: SessionMeta = {
messageCount: lines.length, messageCount: lines.length,
firstCommand: this.getFirstCommand(sessionPath, lines), firstCommand: this.getFirstCommand(sessionPath, lines),
lastModifiedAt: lastModifiedUnixTime
? new Date(lastModifiedUnixTime).toISOString()
: null,
}; };
this.sessionMetaCache.save(sessionId, sessionMeta); this.sessionMetaCache.save(sessionId, sessionMeta);

View File

@@ -5,6 +5,7 @@ import type { projectMetaSchema, sessionMetaSchema } from "./schema";
export type Project = { export type Project = {
id: string; id: string;
claudeProjectPath: string; claudeProjectPath: string;
lastModifiedAt: Date;
meta: ProjectMeta; meta: ProjectMeta;
}; };
@@ -13,6 +14,7 @@ export type ProjectMeta = z.infer<typeof projectMetaSchema>;
export type Session = { export type Session = {
id: string; id: string;
jsonlFilePath: string; jsonlFilePath: string;
lastModifiedAt: Date;
meta: SessionMeta; meta: SessionMeta;
}; };