diff --git a/.vscode/settings.json b/.vscode/settings.json index 4e0d5b7..c3b651b 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,7 +7,27 @@ "biome.enabled": true, // autofix "editor.formatOnSave": false, - "[typescript][typescriptreact][javascript][javascriptreact][json][jsonc][json][yaml]": { + "[typescript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascript]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { "editor.formatOnSave": true, "editor.defaultFormatter": "biomejs.biome" }, diff --git a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts index 88e0b2d..72d49ab 100644 --- a/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts +++ b/src/app/projects/[projectId]/components/chatForm/useChatMutations.ts @@ -32,13 +32,7 @@ export const useNewChatMutation = ( }, onSuccess: async (response) => { onSuccess?.(); - router.push( - `/projects/${projectId}/sessions/${response.sessionId}` + - response.userMessageId !== - undefined - ? `#message-${response.userMessageId}` - : "", - ); + router.push(`/projects/${projectId}/sessions/${response.sessionId}`); }, }); }; @@ -70,9 +64,7 @@ export const useResumeChatMutation = (projectId: string, sessionId: string) => { }, onSuccess: async (response) => { if (sessionId !== response.sessionId) { - router.push( - `/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`, - ); + router.push(`/projects/${projectId}/sessions/${response.sessionId}`); } }, }); diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts index d0d79a0..2b32e02 100644 --- a/src/server/hono/initialize.ts +++ b/src/server/hono/initialize.ts @@ -27,13 +27,13 @@ export const initialize = async (deps: { console.log("Initializing sessions cache"); const results = await Promise.all( - projects.map((project) => deps.sessionRepository.getSessions(project.id)) + projects.map((project) => deps.sessionRepository.getSessions(project.id)), ); console.log( `${results.reduce( (s, { sessions }) => s + sessions.length, - 0 - )} sessions cache initialized` + 0, + )} sessions cache initialized`, ); } catch { // do nothing diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index e033cc9..c437935 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -337,7 +337,6 @@ export const routes = async (app: HonoAppType) => { return c.json({ taskId: task.id, sessionId: task.sessionId, - userMessageId: task.userMessageId, }); }, ) @@ -373,7 +372,6 @@ export const routes = async (app: HonoAppType) => { return c.json({ taskId: task.id, sessionId: task.sessionId, - userMessageId: task.userMessageId, }); }, ) @@ -385,7 +383,6 @@ export const routes = async (app: HonoAppType) => { id: task.id, status: task.status, sessionId: task.sessionId, - userMessageId: task.userMessageId, }), ), }); diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts index 34d71fc..ed333e9 100644 --- a/src/server/lib/storage/FileCacheStorage.ts +++ b/src/server/lib/storage/FileCacheStorage.ts @@ -13,7 +13,7 @@ export class FileCacheStorage { public static load( key: string, - schema: z.ZodType + schema: z.ZodType, ) { const instance = new FileCacheStorage(key); diff --git a/src/server/lib/storage/InMemoryCacheStorage.ts b/src/server/lib/storage/InMemoryCacheStorage.ts index 5433d07..8f19e5e 100644 --- a/src/server/lib/storage/InMemoryCacheStorage.ts +++ b/src/server/lib/storage/InMemoryCacheStorage.ts @@ -1,8 +1,6 @@ export class InMemoryCacheStorage { private storage = new Map(); - public constructor() {} - public get(key: string) { return this.storage.get(key); } diff --git a/src/server/service/claude-code/ClaudeCodeExecutor.ts b/src/server/service/claude-code/ClaudeCodeExecutor.ts index d3c1678..b9de1b2 100644 --- a/src/server/service/claude-code/ClaudeCodeExecutor.ts +++ b/src/server/service/claude-code/ClaudeCodeExecutor.ts @@ -23,13 +23,17 @@ export class ClaudeCodeExecutor { ); } - public get features() { + public get version() { + return this.claudeCodeVersion?.version; + } + + public get availableFeatures() { return { - enableToolApproval: + canUseTool: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 82 }), ) ?? false, - extractUuidFromSDKMessage: + uuidOnSDKMessage: this.claudeCodeVersion?.greaterThanOrEqual( new ClaudeCodeVersion({ major: 1, minor: 0, patch: 86 }), ) ?? false, @@ -44,7 +48,7 @@ export class ClaudeCodeExecutor { options: { pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable, ...baseOptions, - ...(this.features.enableToolApproval ? { canUseTool } : {}), + ...(this.availableFeatures.canUseTool ? { canUseTool } : {}), }, }); } diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index 765f026..f7ad96c 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -2,6 +2,7 @@ import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; import { eventBus } from "../events/EventBus"; +import { predictSessionsDatabase } from "../session/PredictSessionsDatabase"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; import type { @@ -168,9 +169,20 @@ export class ClaudeCodeTaskController { ); if (existingTask) { + console.log( + `Alive task for session(id=${currentSession.sessionId}) continued.`, + ); const result = await this.continueTask(existingTask, message); return result; } else { + if (currentSession.sessionId === undefined) { + console.log(`New task started.`); + } else { + console.log( + `New task started for existing session(id=${currentSession.sessionId}).`, + ); + } + const result = await this.startTask(currentSession, message); return result; } @@ -188,7 +200,7 @@ export class ClaudeCodeTaskController { projectId: string; sessionId?: string; }, - message: string, + userMessage: string, ) { const { generateMessages, @@ -196,7 +208,7 @@ export class ClaudeCodeTaskController { setFirstMessagePromise, resolveFirstMessage, awaitFirstMessage, - } = createMessageGenerator(message); + } = createMessageGenerator(userMessage); const task: PendingClaudeCodeTask = { status: "pending", @@ -252,43 +264,62 @@ export class ClaudeCodeTaskController { }); } - // 初回の system message だとまだ history ファイルが作成されていないので - if (message.type === "user" || message.type === "assistant") { - // 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある - if (!resolved) { - console.log( - "[DEBUG startTask] 10. Resolving task for first time", - ); - - const runningTask: RunningClaudeCodeTask = { - status: "running", - id: task.id, - projectId: task.projectId, - cwd: task.cwd, - generateMessages: task.generateMessages, - setNextMessage: task.setNextMessage, - resolveFirstMessage: task.resolveFirstMessage, - setFirstMessagePromise: task.setFirstMessagePromise, - awaitFirstMessage: task.awaitFirstMessage, - onMessageHandlers: task.onMessageHandlers, - userMessageId: message.uuid, - sessionId: message.session_id, - abortController: abortController, - }; - this.tasks.push(runningTask); - console.log( - "[DEBUG startTask] 11. About to call aliveTaskResolve", - ); - aliveTaskResolve(runningTask); - resolved = true; - console.log( - "[DEBUG startTask] 12. aliveTaskResolve called, resolved=true", - ); - } - - resolveFirstMessage(); + if ( + message.type === "system" && + message.subtype === "init" && + currentSession.sessionId === undefined + ) { + // 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, + conversations: [ + { + type: "user", + message: { + role: "user", + content: userMessage, + }, + isSidechain: false, + userType: "external", + cwd: message.cwd, + sessionId: message.session_id, + version: this.claudeCode.version?.toString() ?? "unknown", + uuid: message.uuid, + timestamp: new Date().toISOString(), + parentUuid: null, + }, + ], + meta: { + firstCommand: null, + lastModifiedAt: new Date().toISOString(), + messageCount: 0, + }, + }); } + if (!resolved) { + const runningTask: RunningClaudeCodeTask = { + status: "running", + id: task.id, + projectId: task.projectId, + cwd: task.cwd, + generateMessages: task.generateMessages, + setNextMessage: task.setNextMessage, + resolveFirstMessage: task.resolveFirstMessage, + setFirstMessagePromise: task.setFirstMessagePromise, + awaitFirstMessage: task.awaitFirstMessage, + onMessageHandlers: task.onMessageHandlers, + sessionId: message.session_id, + abortController: abortController, + }; + this.tasks.push(runningTask); + aliveTaskResolve(runningTask); + resolved = true; + } + + resolveFirstMessage(); + await Promise.all( task.onMessageHandlers.map(async (onMessageHandler) => { await onMessageHandler(message); @@ -302,6 +333,7 @@ export class ClaudeCodeTaskController { }); resolved = true; setFirstMessagePromise(); + predictSessionsDatabase.deletePredictSession(currentTask.sessionId); } } @@ -372,7 +404,6 @@ export class ClaudeCodeTaskController { awaitFirstMessage: task.awaitFirstMessage, onMessageHandlers: task.onMessageHandlers, baseSessionId: task.baseSessionId, - userMessageId: task.userMessageId, }); } diff --git a/src/server/service/claude-code/ClaudeCodeVersion.ts b/src/server/service/claude-code/ClaudeCodeVersion.ts index db3b4f3..9193a7e 100644 --- a/src/server/service/claude-code/ClaudeCodeVersion.ts +++ b/src/server/service/claude-code/ClaudeCodeVersion.ts @@ -43,6 +43,10 @@ export class ClaudeCodeVersion { return this.version.patch; } + public toString() { + return `${this.major}.${this.minor}.${this.patch}`; + } + public equals(other: ClaudeCodeVersion) { return ( this.version.major === other.version.major && diff --git a/src/server/service/claude-code/types.ts b/src/server/service/claude-code/types.ts index aee0526..c920d14 100644 --- a/src/server/service/claude-code/types.ts +++ b/src/server/service/claude-code/types.ts @@ -20,21 +20,18 @@ export type PendingClaudeCodeTask = BaseClaudeCodeTask & { export type RunningClaudeCodeTask = BaseClaudeCodeTask & { status: "running"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; }; export type PausedClaudeCodeTask = BaseClaudeCodeTask & { status: "paused"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; }; type CompletedClaudeCodeTask = BaseClaudeCodeTask & { status: "completed"; sessionId: string; - userMessageId: string | undefined; abortController: AbortController; resolveFirstMessage: () => void; }; @@ -56,7 +53,7 @@ export type AliveClaudeCodeTask = RunningClaudeCodeTask | PausedClaudeCodeTask; export type SerializableAliveTask = Pick< AliveClaudeCodeTask, - "id" | "status" | "sessionId" | "userMessageId" + "id" | "status" | "sessionId" >; export type PermissionRequest = { diff --git a/src/server/service/project/id.ts b/src/server/service/project/id.ts index 5b9f64c..f52d713 100644 --- a/src/server/service/project/id.ts +++ b/src/server/service/project/id.ts @@ -1,3 +1,5 @@ +import { dirname } from "node:path"; + export const encodeProjectId = (fullPath: string) => { return Buffer.from(fullPath).toString("base64url"); }; @@ -5,3 +7,7 @@ export const encodeProjectId = (fullPath: string) => { export const decodeProjectId = (id: string) => { return Buffer.from(id, "base64url").toString("utf-8"); }; + +export const encodeProjectIdFromSessionFilePath = (sessionFilePath: string) => { + return encodeProjectId(dirname(sessionFilePath)); +}; diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts index 63d616f..e12814c 100644 --- a/src/server/service/project/projectMetaStorage.ts +++ b/src/server/service/project/projectMetaStorage.ts @@ -3,15 +3,15 @@ import { readdir, readFile } from "node:fs/promises"; import { basename, resolve } from "node:path"; import { z } from "zod"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; import { parseJsonl } from "../parseJsonl"; import type { ProjectMeta } from "../types"; import { decodeProjectId } from "./id"; -import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; class ProjectMetaStorage { private projectPathCache = FileCacheStorage.load( "project-path-cache", - z.string().nullable() + z.string().nullable(), ); private projectMetaCache = new InMemoryCacheStorage(); @@ -31,7 +31,7 @@ class ProjectMetaStorage { ({ fullPath: resolve(claudeProjectPath, d.name), stats: statSync(resolve(claudeProjectPath, d.name)), - } as const) + }) as const, ) .sort((a, b) => { return a.stats.mtime.getTime() - b.stats.mtime.getTime(); @@ -70,7 +70,7 @@ class ProjectMetaStorage { } private async extractProjectPathFromJsonl( - filePath: string + filePath: string, ): Promise { const cached = this.projectPathCache.get(filePath); if (cached !== undefined) { diff --git a/src/server/service/session/PredictSessionsDatabase.ts b/src/server/service/session/PredictSessionsDatabase.ts new file mode 100644 index 0000000..161b273 --- /dev/null +++ b/src/server/service/session/PredictSessionsDatabase.ts @@ -0,0 +1,34 @@ +import { encodeProjectIdFromSessionFilePath } from "../project/id"; +import type { Session, SessionDetail } from "../types"; + +/** + * For interactively experience, handle sessions not already persisted to the filesystem. + */ +class PredictSessionsDatabase { + private storage = new Map(); + + public getPredictSessions(projectId: string): Session[] { + return Array.from(this.storage.values()).filter( + ({ jsonlFilePath }) => + encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId, + ); + } + + public getPredictSession(sessionId: string): SessionDetail { + const session = this.storage.get(sessionId); + if (!session) { + throw new Error("Session not found"); + } + return session; + } + + public createPredictSession(session: SessionDetail) { + this.storage.set(session.id, session); + } + + public deletePredictSession(sessionId: string) { + this.storage.delete(sessionId); + } +} + +export const predictSessionsDatabase = new PredictSessionsDatabase(); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts index 7242025..2fc9c28 100644 --- a/src/server/service/session/SessionRepository.ts +++ b/src/server/service/session/SessionRepository.ts @@ -1,9 +1,11 @@ +import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { parseJsonl } from "../parseJsonl"; import { decodeProjectId } from "../project/id"; import type { Session, SessionDetail } from "../types"; import { decodeSessionId, encodeSessionId } from "./id"; +import { predictSessionsDatabase } from "./PredictSessionsDatabase"; import { sessionMetaStorage } from "./sessionMetaStorage"; const getTime = (date: string | null) => { @@ -19,6 +21,17 @@ export class SessionRepository { session: SessionDetail; }> { const sessionPath = decodeSessionId(projectId, sessionId); + if (!existsSync(sessionPath)) { + const predictSession = + predictSessionsDatabase.getPredictSession(sessionId); + if (predictSession) { + return { + session: predictSession, + }; + } + + throw new Error("Session not found"); + } const content = await readFile(sessionPath, "utf-8"); const conversations = parseJsonl(content); @@ -53,9 +66,16 @@ export class SessionRepository { ), })), ); + const sessionMap = new Map( + sessions.map((session) => [session.id, session]), + ); + + const predictSessions = predictSessionsDatabase + .getPredictSessions(projectId) + .filter((session) => !sessionMap.has(session.id)); return { - sessions: sessions.sort((a, b) => { + sessions: [...predictSessions, ...sessions].sort((a, b) => { return ( getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) ); diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts index b1dc26a..975db76 100644 --- a/src/server/service/session/sessionMetaStorage.ts +++ b/src/server/service/session/sessionMetaStorage.ts @@ -1,16 +1,15 @@ import { statSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; import { type ParsedCommand, parseCommandXml, parsedCommandSchema, } from "../parseCommandXml"; import { parseJsonl } from "../parseJsonl"; -import { sessionMetaSchema } from "../schema"; import type { SessionMeta } from "../types"; import { decodeSessionId } from "./id"; -import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; const ignoreCommands = [ "/clear", @@ -24,13 +23,13 @@ const ignoreCommands = [ class SessionMetaStorage { private firstCommandCache = FileCacheStorage.load( "first-command-cache", - parsedCommandSchema + parsedCommandSchema, ); private sessionMetaCache = new InMemoryCacheStorage(); public async getSessionMeta( projectId: string, - sessionId: string + sessionId: string, ): Promise { const cached = this.sessionMetaCache.get(sessionId); if (cached !== undefined) { @@ -60,7 +59,7 @@ class SessionMetaStorage { private getFirstCommand = ( jsonlFilePath: string, - lines: string[] + lines: string[], ): ParsedCommand | null => { const cached = this.firstCommandCache.get(jsonlFilePath); if (cached !== undefined) { @@ -80,14 +79,14 @@ class SessionMetaStorage { conversation === null ? null : typeof conversation.message.content === "string" - ? conversation.message.content - : (() => { - const firstContent = conversation.message.content.at(0); - if (firstContent === undefined) return null; - if (typeof firstContent === "string") return firstContent; - if (firstContent.type === "text") return firstContent.text; - return null; - })(); + ? conversation.message.content + : (() => { + const firstContent = conversation.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })(); if (firstUserText === null) { continue;