feat: implement continue chat (not resume if connected)

This commit is contained in:
d-kimsuon
2025-09-03 01:43:03 +09:00
parent 60b9c658f5
commit 79794be526
9 changed files with 388 additions and 219 deletions

View File

@@ -28,8 +28,10 @@ export const useConfig = () => {
}); });
return await response.json(); return await response.json();
}, },
onSuccess: () => { onSuccess: async () => {
queryClient.invalidateQueries({ queryKey: configQueryConfig.queryKey }); await queryClient.invalidateQueries({
queryKey: configQueryConfig.queryKey,
});
}, },
}); });

View File

@@ -1,4 +1,4 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react"; import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { type FC, useId, useRef, useState } from "react"; import { type FC, useId, useRef, useState } from "react";
@@ -16,6 +16,7 @@ export const NewChat: FC<{
}> = ({ projectId, onSuccess }) => { }> = ({ projectId, onSuccess }) => {
const router = useRouter(); const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
const startNewChat = useMutation({ const startNewChat = useMutation({
mutationFn: async (options: { message: string }) => { mutationFn: async (options: { message: string }) => {
@@ -30,13 +31,17 @@ export const NewChat: FC<{
throw new Error(response.statusText); throw new Error(response.statusText);
} }
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
return response.json(); return response.json();
}, },
onSuccess: (response) => { onSuccess: async (response) => {
setMessage(""); setMessage("");
onSuccess?.(); onSuccess?.();
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
router.push( router.push(
`/projects/${projectId}/sessions/${response.nextSessionId}#message-${response.userMessageId}`, `/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
); );
}, },
}); });

View File

@@ -1,14 +1,14 @@
"use client"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { ArrowLeftIcon, LoaderIcon, XIcon } from "lucide-react"; import { ArrowLeftIcon, LoaderIcon, PauseIcon, XIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { honoClient } from "../../../../../../lib/api/client"; import { honoClient } from "../../../../../../lib/api/client";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle"; import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { useIsResummingTask } from "../hooks/useIsResummingTask"; import { useAliveTask } from "../hooks/useAliveTask";
import { useSession } from "../hooks/useSession"; import { useSession } from "../hooks/useSession";
import { ConversationList } from "./conversationList/ConversationList"; import { ConversationList } from "./conversationList/ConversationList";
import { ResumeChat } from "./resumeChat/ResumeChat"; import { ResumeChat } from "./resumeChat/ResumeChat";
@@ -37,15 +37,16 @@ export const SessionPageContent: FC<{
}, },
}); });
const { isResummingTask } = useIsResummingTask(sessionId); const { isRunningTask, isPausedTask } = useAliveTask(sessionId);
const [previouConversationLength, setPreviouConversationLength] = useState(0); const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null); const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理 // 自動スクロール処理
useEffect(() => { useEffect(() => {
if (isResummingTask && conversations.length !== previouConversationLength) { if (isRunningTask && conversations.length !== previousConversationLength) {
setPreviouConversationLength(conversations.length); setPreviousConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current; const scrollContainer = scrollContainerRef.current;
if (scrollContainer) { if (scrollContainer) {
scrollContainer.scrollTo({ scrollContainer.scrollTo({
@@ -54,7 +55,7 @@ export const SessionPageContent: FC<{
}); });
} }
} }
}, [conversations, isResummingTask, previouConversationLength]); }, [conversations, isRunningTask, previousConversationLength]);
return ( return (
<div className="flex h-screen"> <div className="flex h-screen">
@@ -85,12 +86,33 @@ export const SessionPageContent: FC<{
</h1> </h1>
</div> </div>
{isResummingTask && ( {isRunningTask && (
<div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg"> <div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg">
<LoaderIcon className="w-4 h-4 animate-spin" /> <LoaderIcon className="w-4 h-4 animate-spin" />
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium"> <p className="text-sm font-medium">
Conversation is being resumed... Conversation is in progress...
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(sessionId);
}}
>
<XIcon className="w-4 h-4" />
Abort
</Button>
</div>
)}
{isPausedTask && (
<div className="flex items-center gap-2 p-3 bg-primary/10 border border-primary/20 rounded-lg">
<PauseIcon className="w-4 h-4" />
<div className="flex-1">
<p className="text-sm font-medium">
Conversation is paused...
</p> </p>
</div> </div>
<Button <Button
@@ -118,7 +140,11 @@ export const SessionPageContent: FC<{
getToolResult={getToolResult} getToolResult={getToolResult}
/> />
<ResumeChat projectId={projectId} sessionId={sessionId} /> <ResumeChat
projectId={projectId}
sessionId={sessionId}
isPausedTask={isPausedTask}
/>
</main> </main>
</div> </div>
</div> </div>

View File

@@ -26,7 +26,8 @@ import {
export const ResumeChat: FC<{ export const ResumeChat: FC<{
projectId: string; projectId: string;
sessionId: string; sessionId: string;
}> = ({ projectId, sessionId }) => { isPausedTask: boolean;
}> = ({ projectId, sessionId, isPausedTask }) => {
const router = useRouter(); const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -44,14 +45,18 @@ export const ResumeChat: FC<{
throw new Error(response.statusText); throw new Error(response.statusText);
} }
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
return response.json(); return response.json();
}, },
onSuccess: (response) => { onSuccess: async (response) => {
setMessage(""); setMessage("");
queryClient.invalidateQueries({ queryKey: ["runningTasks"] }); await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
if (sessionId !== response.sessionId) {
router.push( router.push(
`/projects/${projectId}/sessions/${response.nextSessionId}#message-${response.userMessageId}`, `/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
); );
}
}, },
}); });
@@ -143,12 +148,17 @@ export const ResumeChat: FC<{
{resumeChat.isPending ? ( {resumeChat.isPending ? (
<> <>
<LoaderIcon className="w-4 h-4 animate-spin" /> <LoaderIcon className="w-4 h-4 animate-spin" />
Starting... Starting... This may take a while.
</>
) : isPausedTask ? (
<>
<SendIcon className="w-4 h-4" />
Send
</> </>
) : ( ) : (
<> <>
<SendIcon className="w-4 h-4" /> <SendIcon className="w-4 h-4" />
Resume Chat Resume
</> </>
)} )}
</Button> </Button>

View File

@@ -0,0 +1,32 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { honoClient } from "../../../../../../lib/api/client";
export const useAliveTask = (sessionId: string) => {
const { data } = useQuery({
queryKey: ["aliveTasks"],
queryFn: async () => {
const response = await honoClient.api.tasks.alive.$get({});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
refetchOnReconnect: true,
});
const taskInfo = useMemo(() => {
const aliveTask = data?.aliveTasks.find(
(task) => task.sessionId === sessionId,
);
return {
aliveTask,
isRunningTask: aliveTask?.status === "running",
isPausedTask: aliveTask?.status === "paused",
} as const;
}, [data, sessionId]);
return taskInfo;
};

View File

@@ -1,45 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { honoClient } from "../../../../../../lib/api/client";
export const useIsResummingTask = (sessionId: string) => {
const { data, isLoading } = useQuery({
queryKey: ["runningTasks"],
queryFn: async () => {
const response = await honoClient.api.tasks.running.$get({});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
// Only poll when there might be running tasks
refetchInterval: (query) => {
const hasRunningTasks = (query.state.data?.runningTasks?.length ?? 0) > 0;
return hasRunningTasks ? 2000 : false; // Poll every 2s when there are tasks, stop when none
},
// Keep data fresh for 30 seconds
staleTime: 30 * 1000,
// Keep in cache for 5 minutes
gcTime: 5 * 60 * 1000,
// Refetch when window regains focus
refetchOnWindowFocus: true,
});
const taskInfo = useMemo(() => {
const runningTask = data?.runningTasks.find(
(task) => task.nextSessionId === sessionId,
);
return {
isResummingTask: Boolean(runningTask),
task: runningTask,
hasRunningTasks: (data?.runningTasks.length ?? 0) > 0,
};
}, [data, sessionId]);
return {
...taskInfo,
isLoading,
};
};

View File

@@ -182,15 +182,19 @@ export const routes = (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
} }
const task = await taskController.createTask({ const task = await taskController.startOrContinueTask(
{
projectId, projectId,
cwd: project.meta.projectPath, cwd: project.meta.projectPath,
},
message, message,
}); );
const { nextSessionId, userMessageId } = return c.json({
await taskController.startTask(task.id); taskId: task.id,
return c.json({ taskId: task.id, nextSessionId, userMessageId }); sessionId: task.sessionId,
userMessageId: task.userMessageId,
});
}, },
) )
@@ -211,21 +215,25 @@ export const routes = (app: HonoAppType) => {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
} }
const task = await taskController.createTask({ const task = await taskController.startOrContinueTask(
{
projectId, projectId,
sessionId, sessionId,
cwd: project.meta.projectPath, cwd: project.meta.projectPath,
message: resumeMessage, },
}); resumeMessage,
);
const { nextSessionId, userMessageId } = return c.json({
await taskController.startTask(task.id); taskId: task.id,
return c.json({ taskId: task.id, nextSessionId, userMessageId }); sessionId: task.sessionId,
userMessageId: task.userMessageId,
});
}, },
) )
.get("/tasks/running", async (c) => { .get("/tasks/alive", async (c) => {
return c.json({ runningTasks: taskController.runningTasks }); return c.json({ aliveTasks: taskController.aliveTasks });
}) })
.post( .post(

View File

@@ -1,15 +1,19 @@
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import { query, type SDKMessage } from "@anthropic-ai/claude-code"; import { query } from "@anthropic-ai/claude-code";
import { ulid } from "ulid"; import { ulid } from "ulid";
import {
type OnMessage = (message: SDKMessage) => void | Promise<void>; createMessageGenerator,
type MessageGenerator,
type OnMessage,
} from "./createMessageGenerator";
type BaseClaudeCodeTask = { type BaseClaudeCodeTask = {
id: string; id: string;
projectId: string; projectId: string;
sessionId?: string | undefined; // undefined = new session baseSessionId?: string | undefined; // undefined = new session
cwd: string; cwd: string;
message: string; generateMessages: MessageGenerator;
setNextMessage: (message: string) => void;
onMessageHandlers: OnMessage[]; onMessageHandlers: OnMessage[];
}; };
@@ -19,29 +23,40 @@ type PendingClaudeCodeTask = BaseClaudeCodeTask & {
type RunningClaudeCodeTask = BaseClaudeCodeTask & { type RunningClaudeCodeTask = BaseClaudeCodeTask & {
status: "running"; status: "running";
nextSessionId: string; sessionId: string;
userMessageId: string;
abortController: AbortController;
};
type PausedClaudeCodeTask = BaseClaudeCodeTask & {
status: "paused";
sessionId: string;
userMessageId: string; userMessageId: string;
abortController: AbortController; abortController: AbortController;
}; };
type CompletedClaudeCodeTask = BaseClaudeCodeTask & { type CompletedClaudeCodeTask = BaseClaudeCodeTask & {
status: "completed"; status: "completed";
nextSessionId: string; sessionId: string;
userMessageId: string; userMessageId: string;
abortController: AbortController;
}; };
type FailedClaudeCodeTask = BaseClaudeCodeTask & { type FailedClaudeCodeTask = BaseClaudeCodeTask & {
status: "failed"; status: "failed";
nextSessionId?: string; sessionId?: string;
userMessageId?: string; userMessageId?: string;
abortController?: AbortController;
}; };
type ClaudeCodeTask = type ClaudeCodeTask =
| PendingClaudeCodeTask
| RunningClaudeCodeTask | RunningClaudeCodeTask
| PausedClaudeCodeTask
| CompletedClaudeCodeTask | CompletedClaudeCodeTask
| FailedClaudeCodeTask; | FailedClaudeCodeTask;
type AliveClaudeCodeTask = RunningClaudeCodeTask | PausedClaudeCodeTask;
export class ClaudeCodeTaskController { export class ClaudeCodeTaskController {
private pathToClaudeCodeExecutable: string; private pathToClaudeCodeExecutable: string;
private tasks: ClaudeCodeTask[] = []; private tasks: ClaudeCodeTask[] = [];
@@ -52,28 +67,185 @@ export class ClaudeCodeTaskController {
.trim(); .trim();
} }
public async createTask( public get aliveTasks() {
taskDef: Omit<ClaudeCodeTask, "id" | "status" | "onMessageHandlers">, return this.tasks.filter(
onMessage?: OnMessage, (task) => task.status === "running" || task.status === "paused",
) { );
const task: ClaudeCodeTask = { }
...taskDef,
id: ulid(),
status: "pending",
onMessageHandlers: typeof onMessage === "function" ? [onMessage] : [],
};
this.tasks.push(task); public async startOrContinueTask(
currentSession: {
cwd: string;
projectId: string;
sessionId?: string;
},
message: string,
): Promise<AliveClaudeCodeTask> {
const existingTask = this.aliveTasks.find(
(task) => task.sessionId === currentSession.sessionId,
);
if (existingTask) {
return this.continueTask(existingTask, message);
} else {
return await this.startTask(currentSession, message);
}
}
private continueTask(task: AliveClaudeCodeTask, message: string) {
task.setNextMessage(message);
return task; return task;
} }
public get pendingTasks() { private startTask(
return this.tasks.filter((task) => task.status === "pending"); currentSession: {
cwd: string;
projectId: string;
sessionId?: string;
},
message: string,
) {
const { generateMessages, setNextMessage } =
createMessageGenerator(message);
const task: PendingClaudeCodeTask = {
status: "pending",
id: ulid(),
projectId: currentSession.projectId,
baseSessionId: currentSession.sessionId,
cwd: currentSession.cwd,
generateMessages,
setNextMessage,
onMessageHandlers: [],
};
let aliveTaskResolve: (task: AliveClaudeCodeTask) => void;
let aliveTaskReject: (error: unknown) => void;
const aliveTaskPromise = new Promise<AliveClaudeCodeTask>(
(resolve, reject) => {
aliveTaskResolve = resolve;
aliveTaskReject = reject;
},
);
let resolved = false;
const handleTask = async () => {
try {
const abortController = new AbortController();
let currentTask: AliveClaudeCodeTask | undefined;
for await (const message of query({
prompt: task.generateMessages(),
options: {
resume: task.baseSessionId,
cwd: task.cwd,
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
permissionMode: "bypassPermissions",
abortController: abortController,
},
})) {
currentTask ??= this.aliveTasks.find((t) => t.id === task.id);
if (currentTask !== undefined && currentTask.status === "paused") {
this.updateExistingTask({
...currentTask,
status: "running",
});
} }
public get runningTasks() { // 初回の system message だとまだ history ファイルが作成されていないので
return this.tasks.filter((task) => task.status === "running"); if (
!resolved &&
(message.type === "user" || message.type === "assistant") &&
message.uuid !== undefined
) {
const runningTask: RunningClaudeCodeTask = {
status: "running",
id: task.id,
projectId: task.projectId,
cwd: task.cwd,
generateMessages: task.generateMessages,
setNextMessage: task.setNextMessage,
onMessageHandlers: task.onMessageHandlers,
userMessageId: message.uuid,
sessionId: message.session_id,
abortController: abortController,
};
this.tasks.push(runningTask);
aliveTaskResolve(runningTask);
resolved = true;
}
await Promise.all(
task.onMessageHandlers.map(async (onMessageHandler) => {
await onMessageHandler(message);
}),
);
if (currentTask !== undefined && message.type === "result") {
this.updateExistingTask({
...currentTask,
status: "paused",
});
}
}
const updatedTask = this.aliveTasks.find((t) => t.id === task.id);
if (updatedTask === undefined) {
const error = new Error(
`illegal state: task is not running, task: ${JSON.stringify(updatedTask)}`,
);
aliveTaskReject(error);
throw error;
}
this.updateExistingTask({
...updatedTask,
status: "completed",
});
} catch (error) {
if (!resolved) {
aliveTaskReject(error);
resolved = true;
}
console.error("Error resuming task", error);
this.updateExistingTask({
...task,
status: "failed",
});
}
};
// continue background
void handleTask();
return aliveTaskPromise;
}
public abortTask(sessionId: string) {
const task = this.aliveTasks.find((task) => task.sessionId === sessionId);
if (!task) {
throw new Error("Alive Task not found");
}
task.abortController.abort();
this.updateExistingTask({
id: task.id,
projectId: task.projectId,
sessionId: task.sessionId,
status: "failed",
cwd: task.cwd,
generateMessages: task.generateMessages,
setNextMessage: task.setNextMessage,
onMessageHandlers: task.onMessageHandlers,
baseSessionId: task.baseSessionId,
userMessageId: task.userMessageId,
});
} }
private updateExistingTask(task: ClaudeCodeTask) { private updateExistingTask(task: ClaudeCodeTask) {
@@ -85,113 +257,4 @@ export class ClaudeCodeTaskController {
Object.assign(target, task); Object.assign(target, task);
} }
public startTask(id: string) {
const task = this.tasks.find((task) => task.id === id);
if (!task) {
throw new Error("Task not found");
}
let runningTaskResolve: (task: RunningClaudeCodeTask) => void;
let runningTaskReject: (error: unknown) => void;
const runningTaskPromise = new Promise<RunningClaudeCodeTask>(
(resolve, reject) => {
runningTaskResolve = resolve;
runningTaskReject = reject;
},
);
let resolved = false;
const handleTask = async () => {
try {
const abortController = new AbortController();
for await (const message of query({
prompt: task.message,
options: {
resume: task.sessionId,
cwd: task.cwd,
pathToClaudeCodeExecutable: this.pathToClaudeCodeExecutable,
permissionMode: "bypassPermissions",
abortController,
},
})) {
// 初回の sysmte message だとまだ history ファイルが作成されていないので
if (
!resolved &&
(message.type === "user" || message.type === "assistant") &&
message.uuid !== undefined
) {
const runningTask: RunningClaudeCodeTask = {
...task,
status: "running",
nextSessionId: message.session_id,
userMessageId: message.uuid,
abortController,
};
this.updateExistingTask(runningTask);
runningTaskResolve(runningTask);
resolved = true;
}
await Promise.all(
task.onMessageHandlers.map(async (onMessageHandler) => {
await onMessageHandler(message);
}),
);
}
if (task.status !== "running") {
const error = new Error(
`illegal state: task is not running, task: ${JSON.stringify(task)}`,
);
runningTaskReject(error);
throw error;
}
this.updateExistingTask({
...task,
status: "completed",
nextSessionId: task.nextSessionId,
userMessageId: task.userMessageId,
});
} catch (error) {
if (!resolved) {
runningTaskReject(error);
resolved = true;
}
console.error("Error resuming task", error);
task.status = "failed";
}
};
// continue background
void handleTask();
return runningTaskPromise;
}
public abortTask(sessionId: string) {
const task = this.tasks
.filter((task) => task.status === "running")
.find((task) => task.nextSessionId === sessionId);
if (!task) {
throw new Error("Running Task not found");
}
task.abortController.abort();
this.updateExistingTask({
id: task.id,
status: "failed",
cwd: task.cwd,
message: task.message,
onMessageHandlers: task.onMessageHandlers,
projectId: task.projectId,
nextSessionId: task.nextSessionId,
sessionId: task.sessionId,
userMessageId: task.userMessageId,
});
}
} }

View File

@@ -0,0 +1,68 @@
import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-code";
export type OnMessage = (message: SDKMessage) => void | Promise<void>;
export type MessageGenerator = () => AsyncGenerator<
SDKUserMessage,
void,
unknown
>;
const createPromise = () => {
let promiseResolve: ((value: string) => void) | undefined;
let promiseReject: ((reason?: unknown) => void) | undefined;
const promise = new Promise<string>((resolve, reject) => {
promiseResolve = resolve;
promiseReject = reject;
});
if (!promiseResolve || !promiseReject) {
throw new Error("Illegal state: Promise not created");
}
return {
promise,
resolve: promiseResolve,
reject: promiseReject,
} as const;
};
export const createMessageGenerator = (
firstMessage: string,
): {
generateMessages: MessageGenerator;
setNextMessage: (message: string) => void;
} => {
let currentPromise = createPromise();
const createMessage = (message: string): SDKUserMessage => {
return {
type: "user",
message: {
role: "user",
content: message,
},
} as SDKUserMessage;
};
async function* generateMessages(): ReturnType<MessageGenerator> {
yield createMessage(firstMessage);
while (true) {
const message = await currentPromise.promise;
currentPromise = createPromise();
yield createMessage(message);
}
}
const setNextMessage = (message: string) => {
currentPromise.resolve(message);
};
return {
generateMessages,
setNextMessage,
};
};