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();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: configQueryConfig.queryKey });
onSuccess: async () => {
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 { useRouter } from "next/navigation";
import { type FC, useId, useRef, useState } from "react";
@@ -16,6 +16,7 @@ export const NewChat: FC<{
}> = ({ projectId, onSuccess }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
const startNewChat = useMutation({
mutationFn: async (options: { message: string }) => {
@@ -30,13 +31,17 @@ export const NewChat: FC<{
throw new Error(response.statusText);
}
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
return response.json();
},
onSuccess: (response) => {
onSuccess: async (response) => {
setMessage("");
onSuccess?.();
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
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";
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 type { FC } from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { honoClient } from "../../../../../../lib/api/client";
import { firstCommandToTitle } from "../../../services/firstCommandToTitle";
import { useIsResummingTask } from "../hooks/useIsResummingTask";
import { useAliveTask } from "../hooks/useAliveTask";
import { useSession } from "../hooks/useSession";
import { ConversationList } from "./conversationList/ConversationList";
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);
// 自動スクロール処理
useEffect(() => {
if (isResummingTask && conversations.length !== previouConversationLength) {
setPreviouConversationLength(conversations.length);
if (isRunningTask && conversations.length !== previousConversationLength) {
setPreviousConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.scrollTo({
@@ -54,7 +55,7 @@ export const SessionPageContent: FC<{
});
}
}
}, [conversations, isResummingTask, previouConversationLength]);
}, [conversations, isRunningTask, previousConversationLength]);
return (
<div className="flex h-screen">
@@ -85,12 +86,33 @@ export const SessionPageContent: FC<{
</h1>
</div>
{isResummingTask && (
{isRunningTask && (
<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" />
<div className="flex-1">
<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>
</div>
<Button
@@ -118,7 +140,11 @@ export const SessionPageContent: FC<{
getToolResult={getToolResult}
/>
<ResumeChat projectId={projectId} sessionId={sessionId} />
<ResumeChat
projectId={projectId}
sessionId={sessionId}
isPausedTask={isPausedTask}
/>
</main>
</div>
</div>

View File

@@ -26,7 +26,8 @@ import {
export const ResumeChat: FC<{
projectId: string;
sessionId: string;
}> = ({ projectId, sessionId }) => {
isPausedTask: boolean;
}> = ({ projectId, sessionId, isPausedTask }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const queryClient = useQueryClient();
@@ -44,14 +45,18 @@ export const ResumeChat: FC<{
throw new Error(response.statusText);
}
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
return response.json();
},
onSuccess: (response) => {
onSuccess: async (response) => {
setMessage("");
queryClient.invalidateQueries({ queryKey: ["runningTasks"] });
router.push(
`/projects/${projectId}/sessions/${response.nextSessionId}#message-${response.userMessageId}`,
);
await queryClient.invalidateQueries({ queryKey: ["aliveTasks"] });
if (sessionId !== response.sessionId) {
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
}
},
});
@@ -143,12 +148,17 @@ export const ResumeChat: FC<{
{resumeChat.isPending ? (
<>
<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" />
Resume Chat
Resume
</>
)}
</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,
};
};