feat: improve interactivity by predict sessions

This commit is contained in:
d-kimsuon
2025-10-15 01:18:14 +09:00
parent 8d592ce89b
commit 94cc1c0630
7 changed files with 86 additions and 65 deletions

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

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

@@ -1,7 +1,8 @@
import prexit from "prexit";
import { ulid } from "ulid";
import type { Config } from "../../config/config";
import { getEventBus, type IEventBus } from "../events/EventBus";
import { eventBus } from "../events/EventBus";
import { predictSessionsDatabase } from "../session/PredictSessionsDatabase";
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
import { createMessageGenerator } from "./createMessageGenerator";
import type {
@@ -169,9 +170,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;
}
@@ -253,32 +265,37 @@ export class ClaudeCodeTaskController {
});
}
// 初回の system message だとまだ history ファイルが作成されていないので
if (message.type === "user" || message.type === "assistant") {
// 本来は message.uuid の存在チェックをしたいが、古いバージョンでは存在しないことがある
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,
userMessageId: message.uuid,
sessionId: message.session_id,
abortController: abortController,
};
this.tasks.push(runningTask);
aliveTaskResolve(runningTask);
resolved = true;
}
eventBus.emit("sessionListChanged", {
projectId: task.projectId,
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,
},
});
}

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

View File

@@ -7,19 +7,19 @@ import type { Session, SessionDetail } from "../types";
class PredictSessionsDatabase {
private storage = new Map<string, SessionDetail>();
private get allPredictSessions() {
return Array.from(this.storage.values());
}
public getPredictSessions(projectId: string): Session[] {
return this.allPredictSessions.filter(
return Array.from(this.storage.values()).filter(
({ jsonlFilePath }) =>
encodeProjectIdFromSessionFilePath(jsonlFilePath) === projectId,
);
}
public getPredictSession(sessionId: string): SessionDetail | null {
return this.storage.get(sessionId) ?? null;
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) {

View File

@@ -1,4 +1,4 @@
import { existsSync, statSync } from "node:fs";
import { existsSync } from "node:fs";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { parseJsonl } from "../parseJsonl";
@@ -19,6 +19,15 @@ export class SessionRepository {
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");
if (predictSession !== null) {
return {
@@ -78,6 +87,13 @@ export class SessionRepository {
(a, b) => b.lastModifiedAt.getTime() - a.lastModifiedAt.getTime(),
),
);
const sessionMap = new Map<string, Session>(
sessions.map((session) => [session.id, session]),
);
const predictSessions = predictSessionsDatabase
.getPredictSessions(projectId)
.filter((session) => !sessionMap.has(session.id));
const sessionMap = new Map(
sessions.map((session) => [session.id, session] as const),
@@ -114,22 +130,11 @@ export class SessionRepository {
});
return {
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,
),
};
}),
)),
],
sessions: [...predictSessions, ...sessions].sort((a, b) => {
return (
getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt)
);
}),
};
} catch (error) {
console.warn(`Failed to read sessions for project ${projectId}:`, error);