diff --git a/scripts/build.sh b/scripts/build.sh index bf1ace9..1dd13e1 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -6,6 +6,10 @@ if [ -d "dist/.next" ]; then rm -rf dist/.next fi +if [ -d "dist/standalone" ]; then + rm -rf dist/standalone +fi + pnpm exec next build cp -r public .next/standalone/ cp -r .next/static .next/standalone/.next/ diff --git a/src/app/components/SSEEventListeners.tsx b/src/app/components/SSEEventListeners.tsx index a80be14..77d6fbc 100644 --- a/src/app/components/SSEEventListeners.tsx +++ b/src/app/components/SSEEventListeners.tsx @@ -25,13 +25,8 @@ export const SSEEventListeners: FC = ({ children }) => { }); }); - useServerEventListener("taskChanged", async ({ aliveTasks, changed }) => { + useServerEventListener("taskChanged", async ({ aliveTasks }) => { setAliveTasks(aliveTasks); - - await queryClient.invalidateQueries({ - queryKey: sessionDetailQuery(changed.projectId, changed.sessionId) - .queryKey, - }); }); return <>{children}; diff --git a/src/app/projects/[projectId]/hooks/useProject.ts b/src/app/projects/[projectId]/hooks/useProject.ts index 9036b75..ff1da81 100644 --- a/src/app/projects/[projectId]/hooks/useProject.ts +++ b/src/app/projects/[projectId]/hooks/useProject.ts @@ -3,9 +3,10 @@ import { projectDetailQuery } from "../../../../lib/api/queries"; export const useProject = (projectId: string) => { return useSuspenseInfiniteQuery({ - queryKey: ["projects", projectId], + queryKey: projectDetailQuery(projectId).queryKey, queryFn: async ({ pageParam }) => { - return await projectDetailQuery(projectId, pageParam).queryFn(); + const result = await projectDetailQuery(projectId, pageParam).queryFn(); + return result; }, initialPageParam: undefined as string | undefined, getNextPageParam: (lastPage) => lastPage.nextCursor, diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index bec1951..ac7a713 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -442,8 +442,19 @@ export const routes = async (app: HonoAppType) => { ) => { stream.writeSSE("taskChanged", { aliveTasks: event.aliveTasks, - changed: event.changed, + changed: { + status: event.changed.status, + sessionId: event.changed.sessionId, + projectId: event.changed.projectId, + }, }); + + if (event.changed.sessionId !== undefined) { + stream.writeSSE("sessionChanged", { + projectId: event.changed.projectId, + sessionId: event.changed.sessionId, + }); + } }; eventBus.on("sessionListChanged", onSessionListChanged); diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index cb630a7..2e987d4 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,6 +1,9 @@ +import { resolve } from "node:path"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; import { eventBus } from "../events/EventBus"; +import { parseCommandXml } from "../parseCommandXml"; +import { decodeProjectId } from "../project/id"; import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; @@ -270,7 +273,10 @@ class ClaudeCodeTaskController { // because it takes time for the Claude Code file to be updated, simulate the message predictSessionsDatabase.createPredictSession({ id: message.session_id, - jsonlFilePath: message.session_id, + jsonlFilePath: resolve( + decodeProjectId(currentSession.projectId), + `${message.session_id}.jsonl`, + ), conversations: [ { type: "user", @@ -289,11 +295,15 @@ class ClaudeCodeTaskController { }, ], meta: { - firstCommand: null, + firstCommand: parseCommandXml(userMessage), messageCount: 0, }, lastModifiedAt: new Date(), }); + + eventBus.emit("sessionListChanged", { + projectId: task.projectId, + }); } if (!resolved) { @@ -421,12 +431,10 @@ class ClaudeCodeTaskController { Object.assign(target, task); } - if (task.status === "paused" || task.status === "running") { - eventBus.emit("taskChanged", { - aliveTasks: this.aliveTasks, - changed: task, - }); - } + eventBus.emit("taskChanged", { + aliveTasks: this.aliveTasks, + changed: task, + }); } } diff --git a/src/server/service/events/InternalEventDeclaration.ts b/src/server/service/events/InternalEventDeclaration.ts index 347fe18..d221353 100644 --- a/src/server/service/events/InternalEventDeclaration.ts +++ b/src/server/service/events/InternalEventDeclaration.ts @@ -1,5 +1,6 @@ import type { AliveClaudeCodeTask, + ClaudeCodeTask, PermissionRequest, } from "../claude-code/types"; @@ -18,7 +19,7 @@ export type InternalEventDeclaration = { taskChanged: { aliveTasks: AliveClaudeCodeTask[]; - changed: AliveClaudeCodeTask; + changed: ClaudeCodeTask; }; permissionRequested: { diff --git a/src/server/service/project/id.test.ts b/src/server/service/project/id.test.ts new file mode 100644 index 0000000..b260238 --- /dev/null +++ b/src/server/service/project/id.test.ts @@ -0,0 +1,33 @@ +import { resolve } from "node:path"; +import { + decodeProjectId, + encodeProjectId, + encodeProjectIdFromSessionFilePath, +} from "./id"; + +const sampleProjectPath = + "/path/to/claude-code-project-dir/projects/sample-project"; +const sampleProjectId = + "L3BhdGgvdG8vY2xhdWRlLWNvZGUtcHJvamVjdC1kaXIvcHJvamVjdHMvc2FtcGxlLXByb2plY3Q"; + +describe("encodeProjectId", () => { + it("should encode project id from project path", () => { + expect(encodeProjectId(sampleProjectPath)).toBe(sampleProjectId); + }); +}); + +describe("decodeProjectId", () => { + it("should decode project absolute path from project id", () => { + expect(decodeProjectId(sampleProjectId)).toBe(sampleProjectPath); + }); +}); + +describe("encodeProjectIdFromSessionFilePath", () => { + it("should encode project id from session file path", () => { + expect( + encodeProjectIdFromSessionFilePath( + resolve(sampleProjectPath, "sample-session-id.jsonl"), + ), + ).toBe(sampleProjectId); + }); +}); diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts index 8dbca71..d26ec5f 100644 --- a/src/server/service/session/PredictSessionsDatabase.ts +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -7,8 +7,12 @@ import type { Session, SessionDetail } from "../types"; class PredictSessionsDatabase { private storage = new Map(); + private get allPredictSessions() { + return Array.from(this.storage.values()); + } + public getPredictSessions(projectId: string): Session[] { - return Array.from(this.storage.values()).filter( + return this.allPredictSessions.filter( ({ jsonlFilePath }) => encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId, ); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 7374cfd..6f43cf2 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -19,7 +19,8 @@ export class SessionRepository { if (!existsSync(sessionPath)) { const predictSession = predictSessionsDatabase.getPredictSession(sessionId); - if (predictSession) { + + if (predictSession !== null) { return { session: predictSession, }; diff --git a/src/server/service/session/id.test.ts b/src/server/service/session/id.test.ts new file mode 100644 index 0000000..f79aa35 --- /dev/null +++ b/src/server/service/session/id.test.ts @@ -0,0 +1,26 @@ +import { resolve } from "node:path"; +import { decodeSessionId, encodeSessionId } from "./id"; + +const sampleProjectId = + "L3BhdGgvdG8vY2xhdWRlLWNvZGUtcHJvamVjdC1kaXIvcHJvamVjdHMvc2FtcGxlLXByb2plY3Q"; +const sampleProjectPath = + "/path/to/claude-code-project-dir/projects/sample-project"; +const sampleSessionId = "1af7fc5e-8455-4414-9ccd-011d40f70b2a"; +const sampleSessionFilePath = resolve( + sampleProjectPath, + `${sampleSessionId}.jsonl`, +); + +describe("encodeSessionId", () => { + it("should encode session id from jsonl file path", () => { + expect(encodeSessionId(sampleSessionFilePath)).toBe(sampleSessionId); + }); +}); + +describe("decodeSessionId", () => { + it("should decode session file absolute path from project id and session id", () => { + expect(decodeSessionId(sampleProjectId, sampleSessionId)).toBe( + sampleSessionFilePath, + ); + }); +}); diff --git a/src/test-setups/vitest.setup.ts b/src/test-setups/vitest.setup.ts new file mode 100644 index 0000000..22bd5e6 --- /dev/null +++ b/src/test-setups/vitest.setup.ts @@ -0,0 +1,3 @@ +afterEach(() => { + vi.clearAllMocks(); +}); diff --git a/src/types/sse.ts b/src/types/sse.ts index 5b26e20..d4def0e 100644 --- a/src/types/sse.ts +++ b/src/types/sse.ts @@ -1,5 +1,6 @@ import type { AliveClaudeCodeTask, + ClaudeCodeTask, PermissionRequest, } from "../server/service/claude-code/types"; @@ -21,7 +22,7 @@ export type SSEEventDeclaration = { taskChanged: { aliveTasks: AliveClaudeCodeTask[]; - changed: AliveClaudeCodeTask; + changed: Pick; }; permission_requested: {