refactor: unify NewChat and ResumeChat Input Component

This commit is contained in:
d-kimsuon
2025-09-07 02:49:00 +09:00
parent 60aaae7a2c
commit 1e31eb4307
7 changed files with 271 additions and 287 deletions

View File

@@ -0,0 +1,146 @@
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { type FC, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea";
import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
export interface ChatInputProps {
projectId: string;
onSubmit: (message: string) => void;
isPending: boolean;
error?: Error | null;
placeholder: string;
buttonText: string;
minHeight?: string;
containerClassName?: string;
disabled?: boolean;
buttonSize?: "sm" | "default" | "lg";
}
export const ChatInput: FC<ChatInputProps> = ({
projectId,
onSubmit,
isPending,
error,
placeholder,
buttonText,
minHeight = "min-h-[100px]",
containerClassName = "",
disabled = false,
buttonSize = "lg",
}) => {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [message, setMessage] = useState("");
const commandCompletionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
if (!message.trim()) return;
onSubmit(message.trim());
setMessage("");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (fileCompletionRef.current?.handleKeyDown(e)) {
return;
}
if (commandCompletionRef.current?.handleKeyDown(e)) {
return;
}
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
};
const handleFileSelect = (filePath: string) => {
setMessage(filePath);
textareaRef.current?.focus();
};
return (
<div className={containerClassName}>
{error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md mb-4">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to send message. Please try again.</span>
</div>
)}
<div className="space-y-3">
<div className="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={`${minHeight} resize-none`}
disabled={isPending || disabled}
maxLength={4000}
aria-label="Message input with completion support"
aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<CommandCompletion
ref={commandCompletionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
/>
{
<FileCompletion
ref={fileCompletionRef}
projectId={projectId}
inputValue={message}
onFileSelect={handleFileSelect}
className="absolute top-full left-0 right-0"
/>
}
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground" id={helpId}>
{message.length}/4000 characters " • Use arrow keys to navigate
completions"
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || isPending || disabled}
size={buttonSize}
className="gap-2"
>
{isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Sending... This may take a while.
</>
) : (
<>
<SendIcon className="w-4 h-4" />
{buttonText}
</>
)}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -128,13 +128,15 @@ export const FileCompletion = forwardRef<
const fullPath = entry.path;
// For directories, add a trailing slash to continue completion (unless forced to close)
const pathToInsert =
entry.type === "directory" && !forceClose ? `${fullPath}/` : fullPath;
// For files or when forced to close, add a space to end completion
// Reconstruct the message with the selected path
const newMessage = `${beforeAt}@${pathToInsert}${afterAt.split(/\s/).slice(1).join(" ")}`;
const remainingText = afterAt.split(/\s/).slice(1).join(" ");
const newMessage =
`${beforeAt}@${fullPath}${remainingText}`.trim() +
(entry.type === "directory" && !forceClose ? "/" : " ");
onFileSelect(newMessage.trim());
onFileSelect(newMessage);
// Close completion if it's a file, or if forced to close
if (entry.type === "file" || forceClose) {

View File

@@ -0,0 +1,7 @@
export type { ChatInputProps } from "./ChatInput";
export { ChatInput } from "./ChatInput";
export type { CommandCompletionRef } from "./CommandCompletion";
export { CommandCompletion } from "./CommandCompletion";
export type { FileCompletionRef } from "./FileCompletion";
export { FileCompletion } from "./FileCompletion";
export { useNewChatMutation, useResumeChatMutation } from "./useChatMutations";

View File

@@ -0,0 +1,75 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { honoClient } from "../../../../../lib/api/client";
export const useNewChatMutation = (
projectId: string,
onSuccess?: () => void,
) => {
const router = useRouter();
return useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"][
"new-session"
].$post(
{
param: { projectId },
json: { message: options.message },
},
{
init: {
signal: AbortSignal.timeout(10 * 1000),
},
},
);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: async (response) => {
onSuccess?.();
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
},
});
};
export const useResumeChatMutation = (projectId: string, sessionId: string) => {
const router = useRouter();
return useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].resume.$post(
{
param: { projectId, sessionId },
json: { resumeMessage: options.message },
},
{
init: {
signal: AbortSignal.timeout(10 * 1000),
},
},
);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: async (response) => {
if (sessionId !== response.sessionId) {
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
}
},
});
};

View File

@@ -1,162 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea";
import { honoClient } from "../../../../../lib/api/client";
import {
CommandCompletion,
type CommandCompletionRef,
} from "./CommandCompletion";
import { FileCompletion, type FileCompletionRef } from "./FileCompletion";
import type { FC } from "react";
import { ChatInput, useNewChatMutation } from "../chatForm";
export const NewChat: FC<{
projectId: string;
onSuccess?: () => void;
}> = ({ projectId, onSuccess }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const startNewChat = useNewChatMutation(projectId, onSuccess);
const startNewChat = useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"][
"new-session"
].$post(
{
param: { projectId },
json: { message: options.message },
},
{
init: {
signal: AbortSignal.timeout(10 * 1000),
},
},
);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: async (response) => {
setMessage("");
onSuccess?.();
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
},
});
const [message, setMessage] = useState("");
const completionRef = useRef<CommandCompletionRef>(null);
const fileCompletionRef = useRef<FileCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
if (!message.trim()) return;
startNewChat.mutate({ message: message.trim() });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずファイル補完のキーボードイベントを処理
if (fileCompletionRef.current?.handleKeyDown(e)) {
return;
}
// 次にコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
}
// 通常のキーボードイベント処理
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
};
const handleFileSelect = (filePath: string) => {
setMessage(filePath);
textareaRef.current?.focus();
const handleSubmit = (message: string) => {
startNewChat.mutate({ message });
};
return (
<div className="space-y-4">
{startNewChat.error && (
<div className="flex items-center gap-2 p-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to start new chat. Please try again.</span>
</div>
)}
<div className="space-y-3">
<div className="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)"
className="min-h-[100px] resize-none"
disabled={startNewChat.isPending}
maxLength={4000}
aria-label="Message input with command completion"
aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<CommandCompletion
ref={completionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
/>
<FileCompletion
ref={fileCompletionRef}
projectId={projectId}
inputValue={message}
onFileSelect={handleFileSelect}
className="absolute top-full left-0 right-0"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground" id={helpId}>
{message.length}/4000 characters Use arrow keys to navigate
completions
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || startNewChat.isPending}
size="lg"
className="gap-2"
>
{startNewChat.isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Sending... This may take a while.
</>
) : (
<>
<SendIcon className="w-4 h-4" />
Start Chat
</>
)}
</Button>
</div>
</div>
</div>
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={startNewChat.isPending}
error={startNewChat.error}
placeholder="Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)"
buttonText="Start Chat"
minHeight="min-h-[100px]"
containerClassName="space-y-4"
/>
);
};

View File

@@ -1,15 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useId, useRef, useState } from "react";
import { Button } from "../../../../../../../components/ui/button";
import { Textarea } from "../../../../../../../components/ui/textarea";
import { honoClient } from "../../../../../../../lib/api/client";
import type { FC } from "react";
import {
CommandCompletion,
type CommandCompletionRef,
} from "../../../../components/newChat/CommandCompletion";
ChatInput,
useResumeChatMutation,
} from "../../../../components/chatForm";
export const ResumeChat: FC<{
projectId: string;
@@ -17,135 +10,32 @@ export const ResumeChat: FC<{
isPausedTask: boolean;
isRunningTask: boolean;
}> = ({ projectId, sessionId, isPausedTask, isRunningTask }) => {
const router = useRouter();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const resumeChat = useResumeChatMutation(projectId, sessionId);
const resumeChat = useMutation({
mutationFn: async (options: { message: string }) => {
const response = await honoClient.api.projects[":projectId"].sessions[
":sessionId"
].resume.$post(
{
param: { projectId, sessionId },
json: { resumeMessage: options.message },
},
{
init: {
signal: AbortSignal.timeout(10 * 1000),
},
},
);
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
onSuccess: async (response) => {
if (sessionId !== response.sessionId) {
router.push(
`/projects/${projectId}/sessions/${response.sessionId}#message-${response.userMessageId}`,
);
}
setMessage("");
},
});
const [message, setMessage] = useState("");
const completionRef = useRef<CommandCompletionRef>(null);
const helpId = useId();
const handleSubmit = () => {
if (!message.trim()) return;
resumeChat.mutate({ message: message.trim() });
const handleSubmit = (message: string) => {
resumeChat.mutate({ message });
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
// まずコマンド補完のキーボードイベントを処理
if (completionRef.current?.handleKeyDown(e)) {
return;
const getButtonText = () => {
if (isPausedTask || isRunningTask) {
return "Send";
}
// 通常のキーボードイベント処理
if (e.key === "Enter" && e.shiftKey) {
e.preventDefault();
handleSubmit();
}
};
const handleCommandSelect = (command: string) => {
setMessage(command);
textareaRef.current?.focus();
return "Resume";
};
return (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6">
{resumeChat.error && (
<div className="flex items-center gap-2 p-3 mb-3 text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded-md">
<AlertCircleIcon className="w-4 h-4" />
<span>Failed to resume chat. Please try again.</span>
</div>
)}
<div className="space-y-2">
<div className="relative">
<Textarea
ref={textareaRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type your message... (Start with / for commands, Shift+Enter to send)"
className="min-h-[60px] resize-none"
disabled={resumeChat.isPending}
maxLength={4000}
aria-label="Message input with command completion"
aria-describedby={helpId}
aria-expanded={message.startsWith("/")}
aria-haspopup="listbox"
role="combobox"
aria-autocomplete="list"
/>
<CommandCompletion
ref={completionRef}
projectId={projectId}
inputValue={message}
onCommandSelect={handleCommandSelect}
className="absolute top-full left-0 right-0"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground" id={helpId}>
{message.length}/4000
</span>
<Button
onClick={handleSubmit}
disabled={!message.trim() || resumeChat.isPending}
size="default"
className="gap-2"
>
{resumeChat.isPending ? (
<>
<LoaderIcon className="w-4 h-4 animate-spin" />
Sending... This may take a while.
</>
) : isPausedTask || isRunningTask ? (
<>
<SendIcon className="w-4 h-4" />
Send
</>
) : (
<>
<SendIcon className="w-4 h-4" />
Resume
</>
)}
</Button>
</div>
</div>
<ChatInput
projectId={projectId}
onSubmit={handleSubmit}
isPending={resumeChat.isPending}
error={resumeChat.error}
placeholder="Type your message... (Start with / for commands, Shift+Enter to send)"
buttonText={getButtonText()}
minHeight="min-h-[60px]"
containerClassName="space-y-2"
buttonSize="default"
/>
</div>
);
};