mirror of
https://github.com/aljazceru/claude-code-viewer.git
synced 2025-12-27 10:14:28 +01:00
refactor: unify NewChat and ResumeChat Input Component
This commit is contained in:
146
src/app/projects/[projectId]/components/chatForm/ChatInput.tsx
Normal file
146
src/app/projects/[projectId]/components/chatForm/ChatInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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) {
|
||||
@@ -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";
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user