feat: improve interactivity by predict sessions

This commit is contained in:
d-kimsuon
2025-10-15 01:18:14 +09:00
parent c7d89d47cd
commit 0259e71b44
15 changed files with 186 additions and 84 deletions

22
.vscode/settings.json vendored
View File

@@ -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"
},

View File

@@ -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}`);
}
},
});

View File

@@ -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

View File

@@ -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,
}),
),
});

View File

@@ -13,7 +13,7 @@ export class FileCacheStorage<const T> {
public static load<const LoadSchema>(
key: string,
schema: z.ZodType<LoadSchema>
schema: z.ZodType<LoadSchema>,
) {
const instance = new FileCacheStorage<LoadSchema>(key);

View File

@@ -1,8 +1,6 @@
export class InMemoryCacheStorage<const T> {
private storage = new Map<string, T>();
public constructor() {}
public get(key: string) {
return this.storage.get(key);
}

View File

@@ -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 } : {}),
},
});
}

View File

@@ -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,14 +264,41 @@ 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",
);
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,
@@ -271,23 +310,15 @@ export class ClaudeCodeTaskController {
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();
}
await Promise.all(
task.onMessageHandlers.map(async (onMessageHandler) => {
@@ -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,
});
}

View File

@@ -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 &&

View File

@@ -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 = {

View File

@@ -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));
};

View File

@@ -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<ProjectMeta>();
@@ -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<string | null> {
const cached = this.projectPathCache.get(filePath);
if (cached !== undefined) {

View File

@@ -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<string, SessionDetail>();
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();

View File

@@ -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<string, Session>(
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)
);

View File

@@ -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<SessionMeta>();
public async getSessionMeta(
projectId: string,
sessionId: string
sessionId: string,
): Promise<SessionMeta> {
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) {