From 52a231bc0a352816913449a90f515eaca18acae9 Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Sat, 6 Sep 2025 23:25:41 +0900 Subject: [PATCH 01/21] fix: bug fix session list doesn't updated after filter config changed --- .../[projectId]/components/ProjectPage.tsx | 8 +---- .../sessionSidebar/MobileSidebar.tsx | 2 +- .../sessionSidebar/SessionSidebar.tsx | 2 +- .../components/sessionSidebar/SettingsTab.tsx | 6 ++-- src/components/SettingsControls.tsx | 35 ++++++++++--------- 5 files changed, 25 insertions(+), 28 deletions(-) diff --git a/src/app/projects/[projectId]/components/ProjectPage.tsx b/src/app/projects/[projectId]/components/ProjectPage.tsx index 3cfa5ac..49619b5 100644 --- a/src/app/projects/[projectId]/components/ProjectPage.tsx +++ b/src/app/projects/[projectId]/components/ProjectPage.tsx @@ -45,12 +45,6 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => { }); }, [config.hideNoUserMessageSession, config.unifySameTitleSession]); - const handleConfigChange = () => { - void queryClient.invalidateQueries({ - queryKey: projectQueryConfig(projectId).queryKey, - }); - }; - return (
@@ -118,7 +112,7 @@ export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
- +
diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx index 1576e76..803b0e8 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/MobileSidebar.tsx @@ -79,7 +79,7 @@ export const MobileSidebar: FC = ({ case "mcp": return ; case "settings": - return ; + return ; default: return null; } diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx index 47f191f..8a4dc76 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SessionSidebar.tsx @@ -54,7 +54,7 @@ export const SessionSidebar: FC<{ case "mcp": return ; case "settings": - return ; + return ; default: return null; } diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SettingsTab.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SettingsTab.tsx index c4164c0..c18af76 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SettingsTab.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/sessionSidebar/SettingsTab.tsx @@ -3,7 +3,9 @@ import type { FC } from "react"; import { SettingsControls } from "@/components/SettingsControls"; -export const SettingsTab: FC = () => { +export const SettingsTab: FC<{ + openingProjectId: string; +}> = ({ openingProjectId }) => { return (
@@ -20,7 +22,7 @@ export const SettingsTab: FC = () => { Session Display - +
diff --git a/src/components/SettingsControls.tsx b/src/components/SettingsControls.tsx index 5d98756..4277e3d 100644 --- a/src/components/SettingsControls.tsx +++ b/src/components/SettingsControls.tsx @@ -1,40 +1,47 @@ "use client"; import { useQueryClient } from "@tanstack/react-query"; -import { type FC, useId } from "react"; +import { type FC, useCallback, useId } from "react"; import { configQueryConfig, useConfig } from "@/app/hooks/useConfig"; import { Checkbox } from "@/components/ui/checkbox"; +import { projectQueryConfig } from "../app/projects/[projectId]/hooks/useProject"; interface SettingsControlsProps { + openingProjectId: string; showLabels?: boolean; showDescriptions?: boolean; className?: string; - onConfigChange?: () => void; } export const SettingsControls: FC = ({ + openingProjectId, showLabels = true, showDescriptions = true, className = "", - onConfigChange, }: SettingsControlsProps) => { const checkboxId = useId(); const { config, updateConfig } = useConfig(); const queryClient = useQueryClient(); + const onConfigChanged = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: configQueryConfig.queryKey, + }); + await queryClient.invalidateQueries({ + queryKey: ["projects"], + }); + void queryClient.invalidateQueries({ + queryKey: projectQueryConfig(openingProjectId).queryKey, + }); + }, [queryClient, openingProjectId]); + const handleHideNoUserMessageChange = async () => { const newConfig = { ...config, hideNoUserMessageSession: !config?.hideNoUserMessageSession, }; updateConfig(newConfig); - await queryClient.invalidateQueries({ - queryKey: configQueryConfig.queryKey, - }); - await queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - onConfigChange?.(); + await onConfigChanged(); }; const handleUnifySameTitleChange = async () => { @@ -43,13 +50,7 @@ export const SettingsControls: FC = ({ unifySameTitleSession: !config?.unifySameTitleSession, }; updateConfig(newConfig); - await queryClient.invalidateQueries({ - queryKey: configQueryConfig.queryKey, - }); - await queryClient.invalidateQueries({ - queryKey: ["projects"], - }); - onConfigChange?.(); + await onConfigChanged(); }; return ( From d0fdadeefacbf8024aa62553d9645e097936fbbf Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Sat, 6 Sep 2025 23:38:49 +0900 Subject: [PATCH 02/21] feat: set timeout for new-chat & resume-chat --- .../[projectId]/components/newChat/NewChat.tsx | 15 +++++++++++---- .../components/resumeChat/ResumeChat.tsx | 15 +++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/app/projects/[projectId]/components/newChat/NewChat.tsx b/src/app/projects/[projectId]/components/newChat/NewChat.tsx index 3e615a2..f7aa95b 100644 --- a/src/app/projects/[projectId]/components/newChat/NewChat.tsx +++ b/src/app/projects/[projectId]/components/newChat/NewChat.tsx @@ -21,10 +21,17 @@ export const NewChat: FC<{ mutationFn: async (options: { message: string }) => { const response = await honoClient.api.projects[":projectId"][ "new-session" - ].$post({ - param: { projectId }, - json: { message: options.message }, - }); + ].$post( + { + param: { projectId }, + json: { message: options.message }, + }, + { + init: { + signal: AbortSignal.timeout(10 * 1000), + }, + }, + ); if (!response.ok) { throw new Error(response.statusText); diff --git a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx index 57911c1..485c46e 100644 --- a/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx +++ b/src/app/projects/[projectId]/sessions/[sessionId]/components/resumeChat/ResumeChat.tsx @@ -24,10 +24,17 @@ export const ResumeChat: FC<{ mutationFn: async (options: { message: string }) => { const response = await honoClient.api.projects[":projectId"].sessions[ ":sessionId" - ].resume.$post({ - param: { projectId, sessionId }, - json: { resumeMessage: options.message }, - }); + ].resume.$post( + { + param: { projectId, sessionId }, + json: { resumeMessage: options.message }, + }, + { + init: { + signal: AbortSignal.timeout(10 * 1000), + }, + }, + ); if (!response.ok) { throw new Error(response.statusText); From 60aaae7a2c11645a2c7c9658e3dbe4b5e1def87e Mon Sep 17 00:00:00 2001 From: d-kimsuon Date: Sun, 7 Sep 2025 00:05:07 +0900 Subject: [PATCH 03/21] feat: add @ file completion --- .../components/newChat/FileCompletion.tsx | 319 ++++++++++++++++++ .../components/newChat/NewChat.tsx | 27 +- src/hooks/useFileCompletion.ts | 40 +++ src/server/hono/route.ts | 32 ++ .../file-completion/getFileCompletion.ts | 96 ++++++ 5 files changed, 510 insertions(+), 4 deletions(-) create mode 100644 src/app/projects/[projectId]/components/newChat/FileCompletion.tsx create mode 100644 src/hooks/useFileCompletion.ts create mode 100644 src/server/service/file-completion/getFileCompletion.ts diff --git a/src/app/projects/[projectId]/components/newChat/FileCompletion.tsx b/src/app/projects/[projectId]/components/newChat/FileCompletion.tsx new file mode 100644 index 0000000..b82e924 --- /dev/null +++ b/src/app/projects/[projectId]/components/newChat/FileCompletion.tsx @@ -0,0 +1,319 @@ +import { CheckIcon, FileIcon, FolderIcon } from "lucide-react"; +import type React from "react"; +import { + forwardRef, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { Button } from "../../../../../components/ui/button"; +import { + Collapsible, + CollapsibleContent, +} from "../../../../../components/ui/collapsible"; +import { + type FileCompletionEntry, + useFileCompletion, +} from "../../../../../hooks/useFileCompletion"; +import { cn } from "../../../../../lib/utils"; + +type FileCompletionProps = { + projectId: string; + inputValue: string; + onFileSelect: (filePath: string) => void; + className?: string; +}; + +export type FileCompletionRef = { + handleKeyDown: (e: React.KeyboardEvent) => boolean; +}; + +// Parse the @ completion from input value +const parseFileCompletionFromInput = (input: string) => { + // Find the last @ symbol + const lastAtIndex = input.lastIndexOf("@"); + if (lastAtIndex === -1) { + return { shouldShow: false, searchPath: "", beforeAt: "", afterAt: "" }; + } + + // Get the text before and after @ + const beforeAt = input.slice(0, lastAtIndex); + const afterAt = input.slice(lastAtIndex + 1); + + // Check if we're in the middle of a word after @ (no space after the path) + const parts = afterAt.split(/\s/); + const searchPath = parts[0] || ""; + + // Don't show completion if there's a space after the path (user has finished typing the path) + // This includes cases like "@hoge " where parts = ["hoge", ""] + const hasSpaceAfterPath = parts.length > 1; + + return { + shouldShow: !hasSpaceAfterPath, + searchPath, + beforeAt, + afterAt, + }; +}; + +export const FileCompletion = forwardRef< + FileCompletionRef, + FileCompletionProps +>(({ projectId, inputValue, onFileSelect, className }, ref) => { + const [isOpen, setIsOpen] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const containerRef = useRef(null); + const listRef = useRef(null); + + // Parse the input to extract the path being completed + const { shouldShow, searchPath, beforeAt, afterAt } = useMemo( + () => parseFileCompletionFromInput(inputValue), + [inputValue], + ); + + // Determine the base path and filter term + const { basePath, filterTerm } = useMemo(() => { + if (!searchPath) { + return { basePath: "/", filterTerm: "" }; + } + + const lastSlashIndex = searchPath.lastIndexOf("/"); + if (lastSlashIndex === -1) { + return { basePath: "/", filterTerm: searchPath }; + } + + const path = searchPath.slice(0, lastSlashIndex + 1); + const term = searchPath.slice(lastSlashIndex + 1); + return { + basePath: path === "/" ? "/" : path, + filterTerm: term, + }; + }, [searchPath]); + + // Fetch file completion data + const { data: completionData, isLoading } = useFileCompletion( + projectId, + basePath, + shouldShow, + ); + + // Filter entries based on the current filter term + const filteredEntries = useMemo(() => { + if (!completionData?.entries) return []; + + if (!filterTerm) { + return completionData.entries; + } + + return completionData.entries.filter((entry) => + entry.name.toLowerCase().includes(filterTerm.toLowerCase()), + ); + }, [completionData?.entries, filterTerm]); + + // Determine if completion should be shown + const shouldBeOpen = shouldShow && !isLoading && filteredEntries.length > 0; + + // Update open state when it should change + if (isOpen !== shouldBeOpen) { + setIsOpen(shouldBeOpen); + setSelectedIndex(-1); + } + + // Handle file/directory selection with different behaviors for different triggers + const handleEntrySelect = useCallback( + (entry: FileCompletionEntry, forceClose = false) => { + 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; + + // Reconstruct the message with the selected path + const newMessage = `${beforeAt}@${pathToInsert}${afterAt.split(/\s/).slice(1).join(" ")}`; + + onFileSelect(newMessage.trim()); + + // Close completion if it's a file, or if forced to close + if (entry.type === "file" || forceClose) { + setIsOpen(false); + setSelectedIndex(-1); + } + }, + [beforeAt, afterAt, onFileSelect], + ); + + // Scroll to selected entry + const scrollToSelected = useCallback(() => { + if (selectedIndex >= 0 && listRef.current) { + const selectedElement = listRef.current.children[ + selectedIndex + 1 + ] as HTMLElement; // +1 for header + if (selectedElement) { + selectedElement.scrollIntoView({ + block: "nearest", + behavior: "smooth", + }); + } + } + }, [selectedIndex]); + + // Keyboard navigation + const handleKeyboardNavigation = useCallback( + (e: React.KeyboardEvent): boolean => { + if (!isOpen || filteredEntries.length === 0) return false; + + switch (e.key) { + case "ArrowDown": + e.preventDefault(); + setSelectedIndex((prev) => { + const newIndex = prev < filteredEntries.length - 1 ? prev + 1 : 0; + setTimeout(scrollToSelected, 0); + return newIndex; + }); + return true; + case "ArrowUp": + e.preventDefault(); + setSelectedIndex((prev) => { + const newIndex = prev > 0 ? prev - 1 : filteredEntries.length - 1; + setTimeout(scrollToSelected, 0); + return newIndex; + }); + return true; + case "Enter": + if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) { + e.preventDefault(); + const selectedEntry = filteredEntries[selectedIndex]; + if (selectedEntry) { + // Enter always closes completion (even for directories) + handleEntrySelect(selectedEntry, true); + } + return true; + } + break; + case "Tab": + if (selectedIndex >= 0 && selectedIndex < filteredEntries.length) { + e.preventDefault(); + const selectedEntry = filteredEntries[selectedIndex]; + if (selectedEntry) { + // Tab: continue completion for directories, close for files + handleEntrySelect(selectedEntry, selectedEntry.type === "file"); + } + return true; + } + break; + case "Escape": + e.preventDefault(); + setIsOpen(false); + setSelectedIndex(-1); + return true; + } + return false; + }, + [ + isOpen, + filteredEntries.length, + selectedIndex, + handleEntrySelect, + scrollToSelected, + filteredEntries, + ], + ); + + // Handle clicks outside the component + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + setSelectedIndex(-1); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + // Expose keyboard handler to parent + useImperativeHandle( + ref, + () => ({ + handleKeyDown: handleKeyboardNavigation, + }), + [handleKeyboardNavigation], + ); + + if (!shouldShow || isLoading || filteredEntries.length === 0) { + return null; + } + + return ( +
+ + +
+ {filteredEntries.length > 0 && ( +
+
+ + Files & Directories ({filteredEntries.length}) + {basePath !== "/" && ( + + in {basePath} + + )} +
+ {filteredEntries.map((entry, index) => ( + + ))} +
+ )} +
+
+
+
+ ); +}); + +FileCompletion.displayName = "FileCompletion"; diff --git a/src/app/projects/[projectId]/components/newChat/NewChat.tsx b/src/app/projects/[projectId]/components/newChat/NewChat.tsx index f7aa95b..a8cd98b 100644 --- a/src/app/projects/[projectId]/components/newChat/NewChat.tsx +++ b/src/app/projects/[projectId]/components/newChat/NewChat.tsx @@ -9,6 +9,7 @@ import { CommandCompletion, type CommandCompletionRef, } from "./CommandCompletion"; +import { FileCompletion, type FileCompletionRef } from "./FileCompletion"; export const NewChat: FC<{ projectId: string; @@ -51,6 +52,7 @@ export const NewChat: FC<{ const [message, setMessage] = useState(""); const completionRef = useRef(null); + const fileCompletionRef = useRef(null); const helpId = useId(); const handleSubmit = () => { @@ -59,7 +61,12 @@ export const NewChat: FC<{ }; const handleKeyDown = (e: React.KeyboardEvent) => { - // まずコマンド補完のキーボードイベントを処理 + // まずファイル補完のキーボードイベントを処理 + if (fileCompletionRef.current?.handleKeyDown(e)) { + return; + } + + // 次にコマンド補完のキーボードイベントを処理 if (completionRef.current?.handleKeyDown(e)) { return; } @@ -76,6 +83,11 @@ export const NewChat: FC<{ textareaRef.current?.focus(); }; + const handleFileSelect = (filePath: string) => { + setMessage(filePath); + textareaRef.current?.focus(); + }; + return (
{startNewChat.error && ( @@ -92,13 +104,13 @@ export const NewChat: FC<{ value={message} onChange={(e) => setMessage(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Type your message here... (Start with / for commands, Shift+Enter to send)" + 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("/")} + aria-expanded={message.startsWith("/") || message.includes("@")} aria-haspopup="listbox" role="combobox" aria-autocomplete="list" @@ -110,12 +122,19 @@ export const NewChat: FC<{ onCommandSelect={handleCommandSelect} className="absolute top-full left-0 right-0" /> +
{message.length}/4000 characters • Use arrow keys to navigate - commands + completions