update design

This commit is contained in:
d-kimsuon
2025-10-17 04:37:09 +09:00
parent 21070d09ff
commit 1795cb499b
54 changed files with 4299 additions and 761 deletions

View File

@@ -11,9 +11,9 @@ import { EventBus } from "../../../server/service/events/EventBus";
import { FileWatcherService } from "../../../server/service/events/fileWatcher"; import { FileWatcherService } from "../../../server/service/events/fileWatcher";
import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService"; import { ProjectMetaService } from "../../../server/service/project/ProjectMetaService";
import { ProjectRepository } from "../../../server/service/project/ProjectRepository"; import { ProjectRepository } from "../../../server/service/project/ProjectRepository";
import { VirtualConversationDatabase } from "../../../server/service/session/PredictSessionsDatabase";
import { SessionMetaService } from "../../../server/service/session/SessionMetaService"; import { SessionMetaService } from "../../../server/service/session/SessionMetaService";
import { SessionRepository } from "../../../server/service/session/SessionRepository"; import { SessionRepository } from "../../../server/service/session/SessionRepository";
import { VirtualConversationDatabase } from "../../../server/service/session/VirtualConversationDatabase";
const program = routes(honoApp); const program = routes(honoApp);

View File

@@ -1,9 +1,13 @@
"use client"; "use client";
import { useTheme } from "next-themes";
import type { FC } from "react"; import type { FC } from "react";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism"; import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
interface MarkdownContentProps { interface MarkdownContentProps {
@@ -15,6 +19,9 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
content, content,
className = "", className = "",
}) => { }) => {
const { resolvedTheme } = useTheme();
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
return ( return (
<div <div
className={`prose prose-neutral dark:prose-invert max-w-none ${className}`} className={`prose prose-neutral dark:prose-invert max-w-none ${className}`}
@@ -136,7 +143,7 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
</span> </span>
</div> </div>
<SyntaxHighlighter <SyntaxHighlighter
style={oneDark} style={syntaxTheme}
language={match[1]} language={match[1]}
PreTag="div" PreTag="div"
className="!mt-0 !rounded-t-none !rounded-b-lg !border-t-0 !border !border-border" className="!mt-0 !rounded-t-none !rounded-b-lg !border-t-0 !border !border-border"

View File

@@ -1,15 +1,62 @@
"use client"; "use client";
import { AlertCircle, Home, RefreshCw } from "lucide-react";
import type { FC, PropsWithChildren } from "react"; import type { FC, PropsWithChildren } from "react";
import { ErrorBoundary } from "react-error-boundary"; import { ErrorBoundary } from "react-error-boundary";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export const RootErrorBoundary: FC<PropsWithChildren> = ({ children }) => { export const RootErrorBoundary: FC<PropsWithChildren> = ({ children }) => {
return ( return (
<ErrorBoundary <ErrorBoundary
FallbackComponent={({ error }) => ( FallbackComponent={({ error, resetErrorBoundary }) => (
<div> <div className="flex min-h-screen items-center justify-center p-4">
<h1>Error</h1> <Card className="w-full max-w-2xl">
<p>{error.message}</p> <CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Something went wrong</CardTitle>
<CardDescription>
An unexpected error occurred in the application
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<code className="text-xs">{error.message}</code>
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={resetErrorBoundary} variant="default">
<RefreshCw />
Try Again
</Button>
<Button
onClick={() => {
window.location.href = "/";
}}
variant="outline"
>
<Home />
Go to Home
</Button>
</div>
</CardContent>
</Card>
</div> </div>
)} )}
> >

View File

@@ -12,14 +12,12 @@ export const SSEEventListeners: FC<PropsWithChildren> = ({ children }) => {
const setSessionProcesses = useSetAtom(sessionProcessesAtom); const setSessionProcesses = useSetAtom(sessionProcessesAtom);
useServerEventListener("sessionListChanged", async (event) => { useServerEventListener("sessionListChanged", async (event) => {
// invalidate session list
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: projectDetailQuery(event.projectId).queryKey, queryKey: projectDetailQuery(event.projectId).queryKey,
}); });
}); });
useServerEventListener("sessionChanged", async (event) => { useServerEventListener("sessionChanged", async (event) => {
// invalidate session detail
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: sessionDetailQuery(event.projectId, event.sessionId).queryKey, queryKey: sessionDetailQuery(event.projectId, event.sessionId).queryKey,
}); });

69
src/app/error.tsx Normal file
View File

@@ -0,0 +1,69 @@
"use client";
import { AlertCircle, Home, RefreshCw } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Something went wrong</CardTitle>
<CardDescription>
An unexpected error occurred in the application
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<code className="text-xs">{error.message}</code>
{error.digest && (
<div className="mt-2 text-xs text-muted-foreground">
Error ID: {error.digest}
</div>
)}
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={reset} variant="default">
<RefreshCw />
Try Again
</Button>
<Button
onClick={() => {
window.location.href = "/";
}}
variant="outline"
>
<Home />
Go to Home
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Toaster } from "../components/ui/sonner"; import { Toaster } from "../components/ui/sonner";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper"; import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
@@ -47,24 +48,26 @@ export default async function RootLayout({
.then((response) => response.json()); .then((response) => response.json());
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<body <body
className={`${geistSans.variable} ${geistMono.variable} antialiased`} className={`${geistSans.variable} ${geistMono.variable} antialiased`}
> >
<RootErrorBoundary> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<QueryClientProviderWrapper> <RootErrorBoundary>
<SSEProvider> <QueryClientProviderWrapper>
<SSEEventListeners> <SSEProvider>
<SyncSessionProcess <SSEEventListeners>
initProcesses={initSessionProcesses.processes} <SyncSessionProcess
> initProcesses={initSessionProcesses.processes}
{children} >
</SyncSessionProcess> {children}
</SSEEventListeners> </SyncSessionProcess>
</SSEProvider> </SSEEventListeners>
</QueryClientProviderWrapper> </SSEProvider>
</RootErrorBoundary> </QueryClientProviderWrapper>
<Toaster position="top-right" /> </RootErrorBoundary>
<Toaster position="top-right" />
</ThemeProvider>
</body> </body>
</html> </html>
); );

41
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,41 @@
import { FileQuestion, Home } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function NotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<FileQuestion className="size-6 text-muted-foreground" />
<div>
<CardTitle>Page Not Found</CardTitle>
<CardDescription>
The page you are looking for does not exist
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="default">
<Link href="/">
<Home />
Go to Home
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,221 +0,0 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import {
ArrowLeftIcon,
ChevronDownIcon,
FolderIcon,
MessageSquareIcon,
PlusIcon,
SettingsIcon,
} from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { SettingsControls } from "@/components/SettingsControls";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { useConfig } from "../../../hooks/useConfig";
import { useProject } from "../hooks/useProject";
import { firstCommandToTitle } from "../services/firstCommandToTitle";
import { NewChatModal } from "./newChat/NewChatModal";
export const ProjectPageContent = ({ projectId }: { projectId: string }) => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useProject(projectId);
const { config } = useConfig();
const queryClient = useQueryClient();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
// Flatten all pages to get project and sessions
const project = data.pages.at(0)?.project;
const sessions = data.pages.flatMap((page) => page.sessions);
if (!project) {
throw new Error("Unreachable: Project must be defined.");
}
// biome-ignore lint/correctness/useExhaustiveDependencies: invalidate when config changed
useEffect(() => {
void queryClient.refetchQueries({
queryKey: ["projects", projectId],
});
}, [config.hideNoUserMessageSession, config.unifySameTitleSession]);
return (
<div className="container mx-auto px-2 sm:px-4 py-4 sm:py-8 max-w-6xl">
<header className="mb-6 sm:mb-8">
<Button asChild variant="ghost" className="mb-4">
<Link href="/projects" className="flex items-center gap-2">
<ArrowLeftIcon className="w-4 h-4" />
<span className="hidden sm:inline">Back to Projects</span>
<span className="sm:hidden">Back</span>
</Link>
</Button>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-2">
<div className="flex items-center gap-2 sm:gap-3 min-w-0 flex-1">
<FolderIcon className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0" />
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold break-words overflow-hidden">
{project.meta.projectPath ?? project.claudeProjectPath}
</h1>
</div>
<div className="flex-shrink-0">
<NewChatModal
projectId={projectId}
trigger={
<Button
size="lg"
className="gap-2 w-full sm:w-auto"
data-testid="new-chat"
>
<PlusIcon className="w-4 h-4 sm:w-5 sm:h-5" />
<span className="hidden sm:inline">Start New Chat</span>
<span className="sm:hidden">New Chat</span>
</Button>
}
/>
</div>
</div>
<p className="text-muted-foreground font-mono text-xs sm:text-sm break-all">
History File: {project.claudeProjectPath ?? "unknown"}
</p>
</header>
<main>
<section>
<h2 className="text-lg sm:text-xl font-semibold mb-4">
Conversation Sessions{" "}
{sessions.length > 0 ? `(${sessions.length})` : ""}
</h2>
{/* Filter Controls */}
<Collapsible open={isSettingsOpen} onOpenChange={setIsSettingsOpen}>
<div className="mb-6">
<CollapsibleTrigger asChild>
<Button
variant="outline"
className="w-full justify-between mb-2 h-auto py-3"
data-testid="expand-filter-settings-button"
>
<div className="flex items-center gap-2">
<SettingsIcon className="w-4 h-4" />
<span className="font-medium">Filter Settings</span>
<span className="text-xs text-muted-foreground">
({sessions.length} sessions)
</span>
</div>
<ChevronDownIcon
className={`w-4 h-4 transition-transform ${
isSettingsOpen ? "rotate-180" : ""
}`}
/>
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="p-4 bg-muted/50 rounded-lg border">
<SettingsControls openingProjectId={projectId} />
</div>
</CollapsibleContent>
</div>
</Collapsible>
{sessions.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<MessageSquareIcon className="w-12 h-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No sessions found</h3>
<p className="text-muted-foreground text-center max-w-md mb-6">
No conversation sessions found for this project. Start a
conversation with Claude Code in this project to create
sessions.
</p>
<NewChatModal
projectId={projectId}
trigger={
<Button size="lg" className="gap-2">
<PlusIcon className="w-5 h-5" />
Start First Chat
</Button>
}
/>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-1 lg:grid-cols-1">
{sessions.map((session) => (
<Card
key={session.id}
className="hover:shadow-md transition-shadow"
>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<span className="break-words overflow-ellipsis line-clamp-2 text-lg sm:text-xl">
{session.meta.firstCommand !== null
? firstCommandToTitle(session.meta.firstCommand)
: session.id}
</span>
</CardTitle>
<CardDescription className="font-mono text-xs">
{session.id}
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<p className="text-sm text-muted-foreground">
{session.meta.messageCount} messages
</p>
<p className="text-sm text-muted-foreground">
Last modified:{" "}
{session.lastModifiedAt
? new Date(session.lastModifiedAt).toLocaleDateString()
: ""}
</p>
<p className="text-xs text-muted-foreground font-mono">
{session.jsonlFilePath}
</p>
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link
href={`/projects/${projectId}/sessions/${encodeURIComponent(
session.id,
)}`}
>
View Session
</Link>
</Button>
</CardContent>
</Card>
))}
</div>
)}
{/* Load More Button */}
{sessions.length > 0 && hasNextPage && (
<div className="mt-6 flex justify-center">
<Button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="lg"
className="min-w-[200px]"
>
{isFetchingNextPage ? "Loading..." : "Load More"}
</Button>
</div>
)}
</section>
</main>
</div>
);
};

View File

@@ -1,4 +1,9 @@
import { AlertCircleIcon, LoaderIcon, SendIcon } from "lucide-react"; import {
AlertCircleIcon,
LoaderIcon,
SendIcon,
SparklesIcon,
} from "lucide-react";
import { type FC, useCallback, useId, useRef, useState } from "react"; import { type FC, useCallback, useId, useRef, useState } from "react";
import { Button } from "../../../../../components/ui/button"; import { Button } from "../../../../../components/ui/button";
import { Textarea } from "../../../../../components/ui/textarea"; import { Textarea } from "../../../../../components/ui/textarea";
@@ -62,16 +67,28 @@ export const ChatInput: FC<ChatInputProps> = ({
// IMEで変換中の場合は送信しない // IMEで変換中の場合は送信しない
if (e.key === "Enter" && !e.nativeEvent.isComposing) { if (e.key === "Enter" && !e.nativeEvent.isComposing) {
const isEnterSend = config?.enterKeyBehavior === "enter-send"; const enterKeyBehavior = config?.enterKeyBehavior;
if (isEnterSend && !e.shiftKey) { if (enterKeyBehavior === "enter-send" && !e.shiftKey && !e.metaKey) {
// Enter: Send mode // Enter: Send mode
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
} else if (!isEnterSend && e.shiftKey) { } else if (
enterKeyBehavior === "shift-enter-send" &&
e.shiftKey &&
!e.metaKey
) {
// Shift+Enter: Send mode (default) // Shift+Enter: Send mode (default)
e.preventDefault(); e.preventDefault();
handleSubmit(); handleSubmit();
} else if (
enterKeyBehavior === "command-enter-send" &&
e.metaKey &&
!e.shiftKey
) {
// Command+Enter: Send mode (Mac)
e.preventDefault();
handleSubmit();
} }
} }
}; };
@@ -148,78 +165,98 @@ export const ChatInput: FC<ChatInputProps> = ({
return ( return (
<div className={containerClassName}> <div className={containerClassName}>
{error && ( {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"> <div className="flex items-center gap-2.5 px-4 py-3 text-sm text-red-600 dark:text-red-400 bg-gradient-to-r from-red-50 to-red-100/50 dark:from-red-950/30 dark:to-red-900/20 border border-red-200/50 dark:border-red-800/50 rounded-xl mb-4 animate-in fade-in slide-in-from-top-2 duration-300 shadow-sm">
<AlertCircleIcon className="w-4 h-4" /> <AlertCircleIcon className="w-4 h-4 shrink-0 mt-0.5" />
<span>Failed to send message. Please try again.</span> <span className="font-medium">
Failed to send message. Please try again.
</span>
</div> </div>
)} )}
<div className="space-y-3"> <div className="relative group">
<div className="relative" ref={containerRef}> <div
<Textarea className="absolute -inset-0.5 bg-gradient-to-r from-blue-500/20 via-purple-500/20 to-pink-500/20 rounded-2xl blur opacity-0 group-hover:opacity-100 transition-opacity duration-500"
ref={textareaRef} aria-hidden="true"
value={message} />
onChange={(e) => {
if ( <div className="relative bg-background border border-border/40 rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden">
e.target.value.endsWith("@") || <div className="relative" ref={containerRef}>
e.target.value.endsWith("/") <Textarea
) { ref={textareaRef}
const position = getCursorPosition(); value={message}
if (position) { onChange={(e) => {
setCursorPosition(position); if (
e.target.value.endsWith("@") ||
e.target.value.endsWith("/")
) {
const position = getCursorPosition();
if (position) {
setCursorPosition(position);
}
} }
}
setMessage(e.target.value); setMessage(e.target.value);
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={placeholder} placeholder={placeholder}
className={`${minHeight} resize-none`} className={`${minHeight} resize-none border-0 focus-visible:ring-0 focus-visible:ring-offset-0 bg-transparent px-5 py-4 text-base transition-all duration-200 placeholder:text-muted-foreground/60`}
disabled={isPending || disabled} disabled={isPending || disabled}
maxLength={4000} maxLength={4000}
aria-label="Message input with completion support" aria-label="Message input with completion support"
aria-describedby={helpId} aria-describedby={helpId}
aria-expanded={message.startsWith("/") || message.includes("@")} aria-expanded={message.startsWith("/") || message.includes("@")}
aria-haspopup="listbox" aria-haspopup="listbox"
role="combobox" role="combobox"
aria-autocomplete="list" aria-autocomplete="list"
/> />
<InlineCompletion </div>
projectId={projectId}
message={message} <div className="flex items-center justify-between gap-3 px-5 py-3 bg-muted/30 border-t border-border/40">
commandCompletionRef={commandCompletionRef} <div className="flex items-center gap-2">
fileCompletionRef={fileCompletionRef} <span
handleCommandSelect={handleCommandSelect} className="text-xs font-medium text-muted-foreground/80"
handleFileSelect={handleFileSelect} id={helpId}
cursorPosition={cursorPosition} >
/> {message.length}
</div> <span className="text-muted-foreground/50">/4000</span>
</span>
<div className="flex items-center justify-between"> {(message.startsWith("/") || message.includes("@")) && (
<span className="text-xs text-muted-foreground" id={helpId}> <span className="text-xs text-blue-600 dark:text-blue-400 font-medium flex items-center gap-1">
{message.length}/4000 characters " • Use arrow keys to navigate <SparklesIcon className="w-3 h-3" />
completions" Autocomplete active
</span> </span>
)}
<Button </div>
onClick={handleSubmit}
disabled={!message.trim() || isPending || disabled} <Button
size={buttonSize} onClick={handleSubmit}
className="gap-2" disabled={!message.trim() || isPending || disabled}
> size={buttonSize}
{isPending ? ( className="gap-2 transition-all duration-200 hover:shadow-md hover:scale-105 active:scale-95 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-500 hover:to-purple-500 disabled:from-muted disabled:to-muted"
<> >
<LoaderIcon className="w-4 h-4 animate-spin" /> {isPending ? (
Sending... This may take a while. <>
</> <LoaderIcon className="w-4 h-4 animate-spin" />
) : ( <span>Processing...</span>
<> </>
<SendIcon className="w-4 h-4" /> ) : (
{buttonText} <>
</> <SendIcon className="w-4 h-4" />
)} {buttonText}
</Button> </>
)}
</Button>
</div>
</div> </div>
<InlineCompletion
projectId={projectId}
message={message}
commandCompletionRef={commandCompletionRef}
fileCompletionRef={fileCompletionRef}
handleCommandSelect={handleCommandSelect}
handleFileSelect={handleFileSelect}
cursorPosition={cursorPosition}
/>
</div> </div>
</div> </div>
); );

View File

@@ -185,49 +185,53 @@ export const CommandCompletion = forwardRef<
<CollapsibleContent> <CollapsibleContent>
<div <div
ref={listRef} ref={listRef}
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto" className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
style={{ height: "15rem" }}
role="listbox" role="listbox"
aria-label="Available commands" aria-label="Available commands"
> >
{filteredCommands.length > 0 && ( <div className="h-full overflow-y-auto">
<div className="p-1"> {filteredCommands.length > 0 && (
<div <div className="p-1.5">
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2" <div
role="presentation" className="px-3 py-2 text-xs font-semibold text-muted-foreground/80 border-b border-border/50 mb-1 flex items-center gap-2"
> role="presentation"
<TerminalIcon className="w-3 h-3" />
Available Commands ({filteredCommands.length})
</div>
{filteredCommands.map((command, index) => (
<Button
key={command}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() => handleCommandSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
> >
<span className="text-muted-foreground mr-1 flex-shrink-0"> <TerminalIcon className="w-3.5 h-3.5" />
/ Available Commands ({filteredCommands.length})
</span> </div>
<span className="font-medium truncate min-w-0"> {filteredCommands.map((command, index) => (
{command} <Button
</span> key={command}
{index === selectedIndex && ( variant="ghost"
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" /> size="sm"
)} className={cn(
</Button> "w-full justify-start text-left font-mono text-sm h-9 px-3 min-w-0 transition-colors duration-150",
))} index === selectedIndex
</div> ? "bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-foreground border border-blue-500/20"
)} : "hover:bg-accent/50",
)}
onClick={() => handleCommandSelect(command)}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`Command: /${command}`}
title={`/${command}`}
>
<span className="text-muted-foreground mr-1.5 flex-shrink-0">
/
</span>
<span className="font-medium truncate min-w-0">
{command}
</span>
{index === selectedIndex && (
<CheckIcon className="w-3.5 h-3.5 ml-auto text-blue-600 dark:text-blue-400 flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View File

@@ -259,63 +259,67 @@ export const FileCompletion = forwardRef<
<CollapsibleContent> <CollapsibleContent>
<div <div
ref={listRef} ref={listRef}
className="absolute z-50 w-full mt-1 bg-popover border border-border rounded-md shadow-lg max-h-48 overflow-y-auto" className="absolute z-50 w-full bg-popover border border-border rounded-lg shadow-xl overflow-hidden"
style={{ height: "15rem" }}
role="listbox" role="listbox"
aria-label="Available files and directories" aria-label="Available files and directories"
> >
{filteredEntries.length > 0 && ( <div className="h-full overflow-y-auto">
<div className="p-1"> {filteredEntries.length > 0 && (
<div <div className="p-1.5">
className="px-2 py-1.5 text-xs font-medium text-muted-foreground border-b border-border mb-1 flex items-center gap-2" <div
role="presentation" className="px-3 py-2 text-xs font-semibold text-muted-foreground/80 border-b border-border/50 mb-1 flex items-center gap-2"
> role="presentation"
<FileIcon className="w-3 h-3" />
Files & Directories ({filteredEntries.length})
{basePath !== "/" && (
<span className="text-xs font-mono text-muted-foreground/70">
in {basePath}
</span>
)}
</div>
{filteredEntries.map((entry, index) => (
<Button
key={entry.path}
variant="ghost"
size="sm"
className={cn(
"w-full justify-start text-left font-mono text-sm h-8 px-2 min-w-0",
index === selectedIndex &&
"bg-accent text-accent-foreground",
)}
onClick={() =>
handleEntrySelect(entry, entry.type === "file")
}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
title={entry.path}
> >
{entry.type === "directory" ? ( <FileIcon className="w-3.5 h-3.5" />
<FolderIcon className="w-3 h-3 mr-2 text-blue-500 flex-shrink-0" /> Files & Directories ({filteredEntries.length})
) : ( {basePath !== "/" && (
<FileIcon className="w-3 h-3 mr-2 text-gray-500 flex-shrink-0" /> <span className="text-xs font-mono text-muted-foreground/70">
)} in {basePath}
<span className="font-medium truncate min-w-0">
{entry.name}
</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1 flex-shrink-0">
/
</span> </span>
)} )}
{index === selectedIndex && ( </div>
<CheckIcon className="w-3 h-3 ml-auto text-primary flex-shrink-0" /> {filteredEntries.map((entry, index) => (
)} <Button
</Button> key={entry.path}
))} variant="ghost"
</div> size="sm"
)} className={cn(
"w-full justify-start text-left font-mono text-sm h-9 px-3 min-w-0 transition-colors duration-150",
index === selectedIndex
? "bg-gradient-to-r from-blue-500/10 to-purple-500/10 text-foreground border border-blue-500/20"
: "hover:bg-accent/50",
)}
onClick={() =>
handleEntrySelect(entry, entry.type === "file")
}
onMouseEnter={() => setSelectedIndex(index)}
role="option"
aria-selected={index === selectedIndex}
aria-label={`${entry.type}: ${entry.name}`}
title={entry.path}
>
{entry.type === "directory" ? (
<FolderIcon className="w-3.5 h-3.5 mr-2 text-blue-500 dark:text-blue-400 flex-shrink-0" />
) : (
<FileIcon className="w-3.5 h-3.5 mr-2 text-gray-500 dark:text-gray-400 flex-shrink-0" />
)}
<span className="font-medium truncate min-w-0">
{entry.name}
</span>
{entry.type === "directory" && (
<span className="text-muted-foreground ml-1 flex-shrink-0">
/
</span>
)}
{index === selectedIndex && (
<CheckIcon className="w-3.5 h-3.5 ml-auto text-blue-600 dark:text-blue-400 flex-shrink-0" />
)}
</Button>
))}
</div>
)}
</div>
</div> </div>
</CollapsibleContent> </CollapsibleContent>
</Collapsible> </Collapsible>

View File

@@ -15,13 +15,21 @@ interface PositionStyle {
const calculateOptimalPosition = ( const calculateOptimalPosition = (
relativeCursorPosition: { top: number; left: number }, relativeCursorPosition: { top: number; left: number },
absoluteCursorPosition: { top: number; left: number }, absoluteCursorPosition: { top: number; left: number },
itemCount: number,
): PositionStyle => { ): PositionStyle => {
const viewportHeight = const viewportHeight =
typeof window !== "undefined" ? window.innerHeight : 800; typeof window !== "undefined" ? window.innerHeight : 800;
const viewportCenter = viewportHeight / 2; const viewportCenter = viewportHeight / 2;
// Estimated completion height (we'll measure actual height later if needed) // Calculate dynamic height based on item count
const estimatedCompletionHeight = 200; // Header: ~48px, Each item: 36px (h-9), Padding: 12px
const headerHeight = 48;
const itemHeight = 36;
const padding = 12;
const maxItems = 5;
const visibleItems = Math.min(itemCount, maxItems);
const estimatedCompletionHeight =
headerHeight + itemHeight * visibleItems + padding;
// Determine preferred placement based on viewport position // Determine preferred placement based on viewport position
const isInUpperHalf = absoluteCursorPosition.top < viewportCenter; const isInUpperHalf = absoluteCursorPosition.top < viewportCenter;
@@ -33,22 +41,22 @@ const calculateOptimalPosition = (
let placement: "above" | "below"; let placement: "above" | "below";
let top: number; let top: number;
if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight) { if (isInUpperHalf && spaceBelow >= estimatedCompletionHeight + 20) {
// Cursor in upper half and enough space below - place below // Cursor in upper half and enough space below - place below
placement = "below"; placement = "below";
top = relativeCursorPosition.top + 16; top = relativeCursorPosition.top + 24;
} else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight) { } else if (!isInUpperHalf && spaceAbove >= estimatedCompletionHeight + 20) {
// Cursor in lower half and enough space above - place above // Cursor in lower half and enough space above - place above
placement = "above"; placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8; top = relativeCursorPosition.top - estimatedCompletionHeight - 16;
} else { } else {
// Use whichever side has more space // Use whichever side has more space
if (spaceBelow > spaceAbove) { if (spaceBelow > spaceAbove) {
placement = "below"; placement = "below";
top = relativeCursorPosition.top + 16; top = relativeCursorPosition.top + 24;
} else { } else {
placement = "above"; placement = "above";
top = relativeCursorPosition.top - estimatedCompletionHeight - 8; top = relativeCursorPosition.top - estimatedCompletionHeight - 16;
} }
} }
@@ -93,6 +101,7 @@ export const InlineCompletion: FC<{
return calculateOptimalPosition( return calculateOptimalPosition(
cursorPosition.relative, cursorPosition.relative,
cursorPosition.absolute, cursorPosition.absolute,
5,
); );
}, [cursorPosition]); }, [cursorPosition]);

View File

@@ -17,10 +17,13 @@ export const NewChat: FC<{
}; };
const getPlaceholder = () => { const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send"; const behavior = config?.enterKeyBehavior;
if (isEnterSend) { if (behavior === "enter-send") {
return "Type your message here... (Start with / for commands, @ for files, Enter to send)"; return "Type your message here... (Start with / for commands, @ for files, Enter to send)";
} }
if (behavior === "command-enter-send") {
return "Type your message here... (Start with / for commands, @ for files, Command+Enter to send)";
}
return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)"; return "Type your message here... (Start with / for commands, @ for files, Shift+Enter to send)";
}; };
@@ -33,7 +36,7 @@ export const NewChat: FC<{
placeholder={getPlaceholder()} placeholder={getPlaceholder()}
buttonText="Start Chat" buttonText="Start Chat"
minHeight="min-h-[200px]" minHeight="min-h-[200px]"
containerClassName="space-y-4" containerClassName="p-6"
/> />
); );
}; };

View File

@@ -0,0 +1,67 @@
"use client";
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
import { useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function ProjectErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Failed to load project</CardTitle>
<CardDescription>
We encountered an error while loading this project
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<code className="text-xs">{error.message}</code>
{error.digest && (
<div className="mt-2 text-xs text-muted-foreground">
Error ID: {error.digest}
</div>
)}
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={reset} variant="default">
<RefreshCw />
Try Again
</Button>
<Button onClick={() => router.push("/projects")} variant="outline">
<ArrowLeft />
Back to Projects
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import { QueryClient } from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { latestSessionQuery } from "../../../../lib/api/queries";
interface LatestSessionPageProps {
params: Promise<{ projectId: string }>;
}
export default async function LatestSessionPage({
params,
}: LatestSessionPageProps) {
const { projectId } = await params;
const queryClient = new QueryClient();
const { latestSession } = await queryClient.fetchQuery(
latestSessionQuery(projectId),
);
if (!latestSession) {
redirect(`/projects`);
}
redirect(
`/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(latestSession.id)}`,
);
}

View File

@@ -0,0 +1,42 @@
import { FolderSearch, Home } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function ProjectNotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<FolderSearch className="size-6 text-muted-foreground" />
<div>
<CardTitle>Project Not Found</CardTitle>
<CardDescription>
The project you are looking for does not exist or has been
removed
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="default">
<Link href="/projects">
<Home />
Back to Projects
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,10 +1,4 @@
import { import { redirect } from "next/navigation";
dehydrate,
HydrationBoundary,
QueryClient,
} from "@tanstack/react-query";
import { projectDetailQuery } from "../../../lib/api/queries";
import { ProjectPageContent } from "./components/ProjectPage";
interface ProjectPageProps { interface ProjectPageProps {
params: Promise<{ projectId: string }>; params: Promise<{ projectId: string }>;
@@ -12,20 +6,5 @@ interface ProjectPageProps {
export default async function ProjectPage({ params }: ProjectPageProps) { export default async function ProjectPage({ params }: ProjectPageProps) {
const { projectId } = await params; const { projectId } = await params;
redirect(`/projects/${encodeURIComponent(projectId)}/latest`);
const queryClient = new QueryClient();
await queryClient.prefetchInfiniteQuery({
queryKey: ["projects", projectId],
queryFn: async ({ pageParam }) => {
return await projectDetailQuery(projectId, pageParam).queryFn();
},
initialPageParam: undefined as string | undefined,
});
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProjectPageContent projectId={projectId} />
</HydrationBoundary>
);
} }

View File

@@ -2,14 +2,12 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { import {
ExternalLinkIcon,
GitCompareIcon, GitCompareIcon,
LoaderIcon, LoaderIcon,
MenuIcon, MenuIcon,
PauseIcon, PauseIcon,
XIcon, XIcon,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog"; import { PermissionDialog } from "@/components/PermissionDialog";
@@ -97,7 +95,7 @@ export const SessionPageContent: FC<{
]); ]);
return ( return (
<div className="flex h-screen max-h-screen overflow-hidden"> <>
<SessionSidebar <SessionSidebar
currentSessionId={sessionId} currentSessionId={sessionId}
projectId={projectId} projectId={projectId}
@@ -127,19 +125,12 @@ export const SessionPageContent: FC<{
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2"> <div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.claudeProjectPath && ( {project?.claudeProjectPath && (
<Link <Badge
href={`/projects/${projectId}`} variant="secondary"
target="_blank" className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
className="transition-all duration-200"
> >
<Badge {project.meta.projectPath ?? project.claudeProjectPath}
variant="secondary" </Badge>
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm transition-all duration-200 cursor-pointer"
>
<ExternalLinkIcon className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
</Link>
)} )}
<Badge <Badge
variant="secondary" variant="secondary"
@@ -150,12 +141,18 @@ export const SessionPageContent: FC<{
</div> </div>
{relatedSessionProcess?.status === "running" && ( {relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5"> <div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" /> <LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
<div className="flex-1"> <div className="flex-1">
<p className="text-xs sm:text-sm font-medium"> <p className="text-xs sm:text-sm font-medium">
Conversation is in progress... Conversation is in progress...
</p> </p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full animate-pulse"
style={{ width: "70%" }}
/>
</div>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
@@ -163,18 +160,23 @@ export const SessionPageContent: FC<{
onClick={() => { onClick={() => {
abortTask.mutate(relatedSessionProcess.id); abortTask.mutate(relatedSessionProcess.id);
}} }}
disabled={abortTask.isPending}
> >
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" /> {abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">Abort</span> <span className="hidden sm:inline">Abort</span>
</Button> </Button>
</div> </div>
)} )}
{relatedSessionProcess?.status === "paused" && ( {relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5"> <div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4" /> <PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<div className="flex-1"> <div className="flex-1">
<p className="text-xs sm:text-sm font-medium"> <p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
Conversation is paused... Conversation is paused...
</p> </p>
</div> </div>
@@ -184,8 +186,14 @@ export const SessionPageContent: FC<{
onClick={() => { onClick={() => {
abortTask.mutate(relatedSessionProcess.id); abortTask.mutate(relatedSessionProcess.id);
}} }}
disabled={abortTask.isPending}
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
> >
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" /> {abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">Abort</span> <span className="hidden sm:inline">Abort</span>
</Button> </Button>
</div> </div>
@@ -204,16 +212,13 @@ export const SessionPageContent: FC<{
/> />
{relatedSessionProcess?.status === "running" && ( {relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8"> <div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<div className="flex items-center gap-2"> <div className="relative">
<div className="flex gap-1"> <LoaderIcon className="w-8 h-8 animate-spin text-primary" />
<div className="w-2 h-2 bg-primary rounded-full animate-bounce"></div> <div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.1s]"></div>
<div className="w-2 h-2 bg-primary rounded-full animate-bounce [animation-delay:0.2s]"></div>
</div>
</div> </div>
<p className="text-sm text-muted-foreground font-medium"> <p className="text-sm text-muted-foreground font-medium animate-pulse">
Claude Code is processing... Claude Code is processing...
</p> </p>
</div> </div>
@@ -255,6 +260,6 @@ export const SessionPageContent: FC<{
isOpen={isDialogOpen} isOpen={isDialogOpen}
onResponse={onPermissionResponse} onResponse={onPermissionResponse}
/> />
</div> </>
); );
}; };

View File

@@ -1,8 +1,14 @@
"use client";
import { ChevronDown, Lightbulb, Settings } from "lucide-react"; import { ChevronDown, Lightbulb, Settings } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes";
import type { FC } from "react"; import type { FC } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism"; import {
oneDark,
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { import {
Card, Card,
@@ -24,6 +30,8 @@ export const AssistantConversationContent: FC<{
content: AssistantMessageContent; content: AssistantMessageContent;
getToolResult: (toolUseId: string) => ToolResultContent | undefined; getToolResult: (toolUseId: string) => ToolResultContent | undefined;
}> = ({ content, getToolResult }) => { }> = ({ content, getToolResult }) => {
const { resolvedTheme } = useTheme();
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
if (content.type === "text") { if (content.type === "text") {
return ( return (
<div className="w-full mx-1 sm:mx-2 my-4 sm:my-6"> <div className="w-full mx-1 sm:mx-2 my-4 sm:my-6">
@@ -34,14 +42,16 @@ export const AssistantConversationContent: FC<{
if (content.type === "thinking") { if (content.type === "thinking") {
return ( return (
<Card className="bg-muted/50 border-dashed gap-2 py-3 mb-2"> <Card className="bg-muted/50 border-dashed gap-2 py-3 mb-2 hover:shadow-sm transition-all duration-200">
<Collapsible> <Collapsible>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-colors py-0 px-4"> <CardHeader className="cursor-pointer hover:bg-muted/80 rounded-t-lg transition-all duration-200 py-0 px-4 group">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-muted-foreground" /> <Lightbulb className="h-4 w-4 text-muted-foreground group-hover:text-yellow-600 transition-colors" />
<CardTitle className="text-sm font-medium">Thinking</CardTitle> <CardTitle className="text-sm font-medium group-hover:text-foreground transition-colors">
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> Thinking
</CardTitle>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div> </div>
</CardHeader> </CardHeader>
</CollapsibleTrigger> </CollapsibleTrigger>
@@ -80,16 +90,16 @@ export const AssistantConversationContent: FC<{
<CardContent className="space-y-2 py-0 px-4"> <CardContent className="space-y-2 py-0 px-4">
<Collapsible> <Collapsible>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2"> <div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
<h4 className="text-xs font-medium text-muted-foreground"> <h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
Input Parameters Input Parameters
</h4> </h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> <ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>
<SyntaxHighlighter <SyntaxHighlighter
style={oneLight} style={syntaxTheme}
language="json" language="json"
PreTag="div" PreTag="div"
className="text-xs" className="text-xs"
@@ -101,11 +111,11 @@ export const AssistantConversationContent: FC<{
{toolResult && ( {toolResult && (
<Collapsible> <Collapsible>
<CollapsibleTrigger asChild> <CollapsibleTrigger asChild>
<div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2"> <div className="flex items-center justify-between cursor-pointer hover:bg-muted/50 rounded p-2 -mx-2 transition-all duration-200 group">
<h4 className="text-xs font-medium text-muted-foreground"> <h4 className="text-xs font-medium text-muted-foreground group-hover:text-foreground">
Tool Result Tool Result
</h4> </h4>
<ChevronDown className="h-3 w-3 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" /> <ChevronDown className="h-3 w-3 text-muted-foreground transition-transform duration-200 group-data-[state=open]:rotate-180" />
</div> </div>
</CollapsibleTrigger> </CollapsibleTrigger>
<CollapsibleContent> <CollapsibleContent>

View File

@@ -143,7 +143,7 @@ export const ConversationList: FC<ConversationListProps> = ({
conversation.type === "summary" conversation.type === "summary"
? "justify-start" ? "justify-start"
: "justify-end" : "justify-end"
}`} } animate-in fade-in slide-in-from-bottom-2 duration-300`}
key={getConversationKey(conversation)} key={getConversationKey(conversation)}
> >
<div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%]"> <div className="w-full max-w-3xl lg:max-w-4xl sm:w-[90%] md:w-[85%]">

View File

@@ -88,7 +88,7 @@ export const UserTextContent: FC<{ text: string; id?: string }> = ({
return ( return (
<MarkdownContent <MarkdownContent
className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50" className="w-full px-3 py-3 mb-5 border border-border rounded-lg bg-slate-50 dark:bg-slate-900/50"
content={parsed.content} content={parsed.content}
/> />
); );

View File

@@ -21,26 +21,32 @@ export const ContinueChat: FC<{
}; };
const getPlaceholder = () => { const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send"; const behavior = config?.enterKeyBehavior;
if (isEnterSend) { if (behavior === "enter-send") {
return "Type your message... (Start with / for commands, Enter to send)"; return "Type your message... (Start with / for commands, @ for files, Enter to send)";
} }
return "Type your message... (Start with / for commands, Shift+Enter to send)"; if (behavior === "command-enter-send") {
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
}
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
}; };
return ( return (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6"> <div className="relative mt-8 mb-6">
<ChatInput <div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
projectId={projectId} <div className="pt-8">
onSubmit={handleSubmit} <ChatInput
isPending={continueSessionProcess.isPending} projectId={projectId}
error={continueSessionProcess.error} onSubmit={handleSubmit}
placeholder={getPlaceholder()} isPending={continueSessionProcess.isPending}
buttonText={"Send"} error={continueSessionProcess.error}
minHeight="min-h-[100px]" placeholder={getPlaceholder()}
containerClassName="space-y-2" buttonText={"Send"}
buttonSize="default" minHeight="min-h-[120px]"
/> containerClassName=""
buttonSize="default"
/>
</div>
</div> </div>
); );
}; };

View File

@@ -20,26 +20,32 @@ export const ResumeChat: FC<{
}; };
const getPlaceholder = () => { const getPlaceholder = () => {
const isEnterSend = config?.enterKeyBehavior === "enter-send"; const behavior = config?.enterKeyBehavior;
if (isEnterSend) { if (behavior === "enter-send") {
return "Type your message... (Start with / for commands, Enter to send)"; return "Type your message... (Start with / for commands, @ for files, Enter to send)";
} }
return "Type your message... (Start with / for commands, Shift+Enter to send)"; if (behavior === "command-enter-send") {
return "Type your message... (Start with / for commands, @ for files, Command+Enter to send)";
}
return "Type your message... (Start with / for commands, @ for files, Shift+Enter to send)";
}; };
return ( return (
<div className="border-t border-border/50 bg-muted/20 p-4 mt-6"> <div className="relative mt-8 mb-6">
<ChatInput <div className="absolute inset-0 bg-gradient-to-r from-transparent via-border to-transparent h-px top-0" />
projectId={projectId} <div className="pt-8">
onSubmit={handleSubmit} <ChatInput
isPending={createSessionProcess.isPending} projectId={projectId}
error={createSessionProcess.error} onSubmit={handleSubmit}
placeholder={getPlaceholder()} isPending={createSessionProcess.isPending}
buttonText={"Resume"} error={createSessionProcess.error}
minHeight="min-h-[100px]" placeholder={getPlaceholder()}
containerClassName="space-y-2" buttonText={"Resume"}
buttonSize="default" minHeight="min-h-[120px]"
/> containerClassName=""
buttonSize="default"
/>
</div>
</div> </div>
); );
}; };

View File

@@ -1,14 +1,15 @@
"use client"; "use client";
import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react"; import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react";
import { type FC, useEffect, useState } from "react"; import { type FC, Suspense, useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject"; import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab"; import { McpTab } from "./McpTab";
import { SessionsTab } from "./SessionsTab"; import { SessionsTab } from "./SessionsTab";
import { SettingsTab } from "./SettingsTab";
interface MobileSidebarProps { interface MobileSidebarProps {
currentSessionId: string; currentSessionId: string;
@@ -89,7 +90,35 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
case "mcp": case "mcp":
return <McpTab projectId={projectId} />; return <McpTab projectId={projectId} />;
case "settings": case "settings":
return <SettingsTab openingProjectId={projectId} />; return (
<div className="h-full flex flex-col">
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-sm text-sidebar-foreground/70">
Loading settings...
</div>
</div>
}
>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Session Display
</h3>
<SettingsControls openingProjectId={projectId} />
</div>
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</Suspense>
</div>
);
default: default:
return null; return null;
} }

View File

@@ -1,19 +1,14 @@
"use client"; "use client";
import { import { MessageSquareIcon, PlugIcon } from "lucide-react";
MessageSquareIcon, import { type FC, useMemo } from "react";
PlugIcon, import type { SidebarTab } from "@/components/GlobalSidebar";
SettingsIcon, import { GlobalSidebar } from "@/components/GlobalSidebar";
Undo2Icon,
} from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject"; import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab"; import { McpTab } from "./McpTab";
import { MobileSidebar } from "./MobileSidebar"; import { MobileSidebar } from "./MobileSidebar";
import { SessionsTab } from "./SessionsTab"; import { SessionsTab } from "./SessionsTab";
import { SettingsTab } from "./SettingsTab";
export const SessionSidebar: FC<{ export const SessionSidebar: FC<{
currentSessionId: string; currentSessionId: string;
@@ -28,7 +23,6 @@ export const SessionSidebar: FC<{
isMobileOpen = false, isMobileOpen = false,
onMobileOpenChange, onMobileOpenChange,
}) => { }) => {
const router = useRouter();
const { const {
data: projectData, data: projectData,
fetchNextPage, fetchNextPage,
@@ -36,26 +30,14 @@ export const SessionSidebar: FC<{
isFetchingNextPage, isFetchingNextPage,
} = useProject(projectId); } = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions); const sessions = projectData.pages.flatMap((page) => page.sessions);
const [activeTab, setActiveTab] = useState<"sessions" | "mcp" | "settings">(
"sessions",
);
const [isExpanded, setIsExpanded] = useState(true);
const handleTabClick = (tab: "sessions" | "mcp" | "settings") => { const additionalTabs: SidebarTab[] = useMemo(
if (activeTab === tab && isExpanded) { () => [
// If clicking the active tab while expanded, collapse {
setIsExpanded(false); id: "sessions",
} else { icon: MessageSquareIcon,
// If clicking a different tab or expanding, show that tab title: "Sessions",
setActiveTab(tab); content: (
setIsExpanded(true);
}
};
const renderContent = () => {
switch (activeTab) {
case "sessions":
return (
<SessionsTab <SessionsTab
sessions={sessions.map((session) => ({ sessions={sessions.map((session) => ({
...session, ...session,
@@ -67,105 +49,34 @@ export const SessionSidebar: FC<{
isFetchingNextPage={isFetchingNextPage} isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()} onLoadMore={() => fetchNextPage()}
/> />
); ),
case "mcp": },
return <McpTab projectId={projectId} />; {
case "settings": id: "mcp",
return <SettingsTab openingProjectId={projectId} />; icon: PlugIcon,
default: title: "MCP Servers",
return null; content: <McpTab projectId={projectId} />,
} },
}; ],
[
const sidebarContent = ( sessions,
<div currentSessionId,
className={cn( projectId,
"h-full border-r border-sidebar-border transition-all duration-300 ease-in-out flex bg-sidebar text-sidebar-foreground", hasNextPage,
isExpanded ? "w-72 lg:w-80" : "w-12", isFetchingNextPage,
)} fetchNextPage,
> ],
{/* Vertical Icon Menu - Always Visible */}
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
<div className="flex flex-col p-2 space-y-1">
<button
type="button"
onClick={() => {
router.push(`/projects/${projectId}`);
}}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"text-sidebar-foreground/70",
)}
title="Back to Project"
>
<Undo2Icon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("sessions")}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === "sessions" && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title="Sessions"
data-testid="sessions-tab-button"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("mcp")}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === "mcp" && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title="MCP Servers"
data-testid="mcp-tab-button"
>
<PlugIcon className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => handleTabClick("settings")}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === "settings" && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title="Settings"
data-testid="settings-tab-button"
>
<SettingsIcon className="w-4 h-4" />
</button>
</div>
</div>
{/* Content Area - Only shown when expanded */}
{isExpanded && (
<div className="flex-1 flex flex-col overflow-hidden">
{renderContent()}
</div>
)}
</div>
); );
return ( return (
<> <>
{/* Desktop sidebar */} {/* Desktop sidebar */}
<div className={cn("hidden md:flex h-full", className)}> <div className={cn("hidden md:flex h-full", className)}>
{sidebarContent} <GlobalSidebar
projectId={projectId}
additionalTabs={additionalTabs}
defaultActiveTab="sessions"
/>
</div> </div>
{/* Mobile sidebar */} {/* Mobile sidebar */}

View File

@@ -103,9 +103,9 @@ export const SessionsTab: FC<{
session.id, session.id,
)}`} )}`}
className={cn( className={cn(
"block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 hover:border-blue-300/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30", "block rounded-lg p-2.5 transition-all duration-200 hover:bg-blue-50/60 dark:hover:bg-blue-950/40 hover:border-blue-300/60 dark:hover:border-blue-700/60 hover:shadow-sm border border-sidebar-border/40 bg-sidebar/30",
isActive && isActive &&
"bg-blue-100 border-blue-400 shadow-md ring-1 ring-blue-200/50 hover:bg-blue-100 hover:border-blue-400", "bg-blue-100 dark:bg-blue-900/50 border-blue-400 dark:border-blue-600 shadow-md ring-1 ring-blue-200/50 dark:ring-blue-700/50 hover:bg-blue-100 dark:hover:bg-blue-900/50 hover:border-blue-400 dark:hover:border-blue-600",
)} )}
> >
<div className="space-y-1.5"> <div className="space-y-1.5">

View File

@@ -1,40 +0,0 @@
"use client";
import type { FC } from "react";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
export const SettingsTab: FC<{
openingProjectId: string;
}> = ({ openingProjectId }) => {
return (
<div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4">
<h2 className="font-semibold text-lg">Settings</h2>
<p className="text-xs text-sidebar-foreground/70">
Display and behavior preferences
</p>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Session Display Settings */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Session Display
</h3>
<SettingsControls openingProjectId={openingProjectId} />
</div>
{/* Notification Settings */}
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,72 @@
"use client";
import { AlertCircle, ArrowLeft, RefreshCw } from "lucide-react";
import { useParams, useRouter } from "next/navigation";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function SessionErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const router = useRouter();
const params = useParams<{ projectId: string }>();
const projectId = params.projectId;
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<AlertCircle className="size-6 text-destructive" />
<div>
<CardTitle>Failed to load session</CardTitle>
<CardDescription>
We encountered an error while loading this conversation session
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Alert variant="destructive">
<AlertCircle />
<AlertTitle>Error Details</AlertTitle>
<AlertDescription>
<code className="text-xs">{error.message}</code>
{error.digest && (
<div className="mt-2 text-xs text-muted-foreground">
Error ID: {error.digest}
</div>
)}
</AlertDescription>
</Alert>
<div className="flex gap-2">
<Button onClick={reset} variant="default">
<RefreshCw />
Try Again
</Button>
<Button
onClick={() => router.push(`/projects/${projectId}`)}
variant="outline"
>
<ArrowLeft />
Back to Project
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,15 @@
"use client";
import type { FC, ReactNode } from "react";
interface SessionLayoutProps {
children: ReactNode;
}
const SessionLayout: FC<SessionLayoutProps> = ({ children }) => {
return (
<div className="flex h-screen max-h-screen overflow-hidden">{children}</div>
);
};
export default SessionLayout;

View File

@@ -0,0 +1,42 @@
import { MessageCircleOff } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export default function SessionNotFoundPage() {
return (
<div className="flex min-h-screen items-center justify-center p-4">
<Card className="w-full max-w-2xl">
<CardHeader>
<div className="flex items-center gap-3">
<MessageCircleOff className="size-6 text-muted-foreground" />
<div>
<CardTitle>Session Not Found</CardTitle>
<CardDescription>
The conversation session you are looking for does not exist or
has been removed
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Button asChild variant="default">
<Link href="/projects">
<MessageCircleOff />
Back to Projects
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -59,8 +59,8 @@ export const ProjectList: FC = () => {
</CardContent> </CardContent>
<CardContent className="pt-0"> <CardContent className="pt-0">
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link href={`/projects/${encodeURIComponent(project.id)}`}> <Link href={`/projects/${encodeURIComponent(project.id)}/latest`}>
View Sessions View Conversations
</Link> </Link>
</Button> </Button>
</CardContent> </CardContent>

View File

@@ -1,38 +1,48 @@
import { QueryClient } from "@tanstack/react-query"; "use client";
import { HistoryIcon } from "lucide-react"; import { HistoryIcon } from "lucide-react";
import { projectListQuery } from "../../lib/api/queries"; import { Suspense } from "react";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import { ProjectList } from "./components/ProjectList"; import { ProjectList } from "./components/ProjectList";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store"; export const fetchCache = "force-no-store";
export default async function ProjectsPage() { export default function ProjectsPage() {
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: projectListQuery.queryKey,
queryFn: projectListQuery.queryFn,
});
return ( return (
<div className="container mx-auto px-4 py-8"> <div className="flex h-screen max-h-screen overflow-hidden">
<header className="mb-8"> <GlobalSidebar className="hidden md:flex" />
<h1 className="text-3xl font-bold mb-2 flex items-center gap-2"> <div className="flex-1 overflow-auto">
<HistoryIcon className="w-8 h-8" /> <div className="container mx-auto px-4 py-8">
Claude Code Viewer <header className="mb-8">
</h1> <h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
<p className="text-muted-foreground"> <HistoryIcon className="w-8 h-8" />
Browse your Claude Code conversation history and project interactions Claude Code Viewer
</p> </h1>
</header> <p className="text-muted-foreground">
Browse your Claude Code conversation history and project
interactions
</p>
</header>
<main> <main>
<section> <section>
<h2 className="text-xl font-semibold mb-4">Your Projects</h2> <h2 className="text-xl font-semibold mb-4">Your Projects</h2>
<Suspense
<ProjectList /> fallback={
</section> <div className="flex items-center justify-center py-12">
</main> <div className="text-muted-foreground">
Loading projects...
</div>
</div>
}
>
<ProjectList />
</Suspense>
</section>
</main>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -0,0 +1,132 @@
"use client";
import type { LucideIcon } from "lucide-react";
import { SettingsIcon } from "lucide-react";
import { type FC, type ReactNode, Suspense, useState } from "react";
import { cn } from "@/lib/utils";
import { NotificationSettings } from "./NotificationSettings";
import { SettingsControls } from "./SettingsControls";
export interface SidebarTab {
id: string;
icon: LucideIcon;
title: string;
content: ReactNode;
}
interface GlobalSidebarProps {
projectId?: string;
className?: string;
additionalTabs?: SidebarTab[];
defaultActiveTab?: string;
}
export const GlobalSidebar: FC<GlobalSidebarProps> = ({
projectId,
className,
additionalTabs = [],
defaultActiveTab,
}) => {
const settingsTab: SidebarTab = {
id: "settings",
icon: SettingsIcon,
title: "Settings",
content: (
<div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4">
<h2 className="font-semibold text-lg">Settings</h2>
<p className="text-xs text-sidebar-foreground/70">
Display and behavior preferences
</p>
</div>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-sm text-sidebar-foreground/70">
Loading settings...
</div>
</div>
}
>
<div className="flex-1 overflow-y-auto p-4 space-y-6">
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Session Display
</h3>
<SettingsControls openingProjectId={projectId ?? ""} />
</div>
<div className="space-y-4">
<h3 className="font-medium text-sm text-sidebar-foreground">
Notifications
</h3>
<NotificationSettings />
</div>
</div>
</Suspense>
</div>
),
};
const allTabs = [...additionalTabs, settingsTab];
const [activeTab, setActiveTab] = useState<string>(
defaultActiveTab ?? allTabs[allTabs.length - 1]?.id ?? "settings",
);
const [isExpanded, setIsExpanded] = useState(!!defaultActiveTab);
const handleTabClick = (tabId: string) => {
if (activeTab === tabId && isExpanded) {
setIsExpanded(false);
} else {
setActiveTab(tabId);
setIsExpanded(true);
}
};
const activeTabContent = allTabs.find((tab) => tab.id === activeTab)?.content;
return (
<div
className={cn(
"h-full border-r border-sidebar-border transition-all duration-300 ease-in-out flex bg-sidebar text-sidebar-foreground",
isExpanded ? "w-72 lg:w-80" : "w-12",
className,
)}
>
{/* Vertical Icon Menu - Always Visible */}
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50">
<div className="flex flex-col p-2 space-y-1">
{allTabs.map((tab) => {
const Icon = tab.icon;
return (
<button
key={tab.id}
type="button"
onClick={() => handleTabClick(tab.id)}
className={cn(
"w-8 h-8 flex items-center justify-center rounded-md transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
activeTab === tab.id && isExpanded
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
title={tab.title}
data-testid={`${tab.id}-tab-button`}
>
<Icon className="w-4 h-4" />
</button>
);
})}
</div>
</div>
{/* Content Area - Only shown when expanded */}
{isExpanded && (
<div className="flex-1 flex flex-col overflow-hidden">
{activeTabContent}
</div>
)}
</div>
);
};

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useTheme } from "next-themes";
import { type FC, useCallback, useId } from "react"; import { type FC, useCallback, useId } from "react";
import { useConfig } from "@/app/hooks/useConfig"; import { useConfig } from "@/app/hooks/useConfig";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
@@ -33,8 +34,10 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
const checkboxId = useId(); const checkboxId = useId();
const enterKeyBehaviorId = useId(); const enterKeyBehaviorId = useId();
const permissionModeId = useId(); const permissionModeId = useId();
const themeId = useId();
const { config, updateConfig } = useConfig(); const { config, updateConfig } = useConfig();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
const onConfigChanged = useCallback(async () => { const onConfigChanged = useCallback(async () => {
await queryClient.refetchQueries({ await queryClient.refetchQueries({
@@ -69,7 +72,10 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
const handleEnterKeyBehaviorChange = async (value: string) => { const handleEnterKeyBehaviorChange = async (value: string) => {
const newConfig = { const newConfig = {
...config, ...config,
enterKeyBehavior: value as "shift-enter-send" | "enter-send", enterKeyBehavior: value as
| "shift-enter-send"
| "enter-send"
| "command-enter-send",
}; };
updateConfig(newConfig); updateConfig(newConfig);
await onConfigChanged(); await onConfigChanged();
@@ -154,6 +160,9 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
Shift+Enter to send (default) Shift+Enter to send (default)
</SelectItem> </SelectItem>
<SelectItem value="enter-send">Enter to send</SelectItem> <SelectItem value="enter-send">Enter to send</SelectItem>
<SelectItem value="command-enter-send">
Command+Enter to send
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{showDescriptions && ( {showDescriptions && (
@@ -197,6 +206,29 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
</p> </p>
)} )}
</div> </div>
<div className="space-y-2">
{showLabels && (
<label htmlFor={themeId} className="text-sm font-medium leading-none">
Theme
</label>
)}
<Select value={theme || "system"} onValueChange={setTheme}>
<SelectTrigger id={themeId} className="w-full">
<SelectValue placeholder="Select theme" />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
{showDescriptions && (
<p className="text-xs text-muted-foreground mt-1">
Choose your preferred color theme
</p>
)}
</div>
</div> </div>
); );
}; };

View File

@@ -33,6 +33,26 @@ export const projectDetailQuery = (projectId: string, cursor?: string) =>
}, },
}) as const; }) as const;
export const latestSessionQuery = (projectId: string) =>
({
queryKey: ["projects", projectId, "latest-session"],
queryFn: async () => {
const response = await honoClient.api.projects[":projectId"][
"latest-session"
].$get({
param: { projectId },
});
if (!response.ok) {
throw new Error(
`Failed to fetch latest session: ${response.statusText}`,
);
}
return response.json();
},
}) as const;
export const sessionDetailQuery = (projectId: string, sessionId: string) => export const sessionDetailQuery = (projectId: string, sessionId: string) =>
({ ({
queryKey: ["projects", projectId, "sessions", sessionId], queryKey: ["projects", projectId, "sessions", sessionId],

View File

@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import type { SSEEvent } from "../../../types/sse"; import type { SSEEvent } from "../../../types/sse";
import { type EventListener, useSSEContext } from "../SSEContext"; import { type EventListener, useSSEContext } from "../SSEContext";
@@ -6,19 +6,24 @@ import { type EventListener, useSSEContext } from "../SSEContext";
* Custom hook to listen for specific SSE events * Custom hook to listen for specific SSE events
* @param eventType - The type of event to listen for * @param eventType - The type of event to listen for
* @param listener - The callback function to execute when the event is received * @param listener - The callback function to execute when the event is received
* @param deps - Dependencies array for the listener function (similar to useEffect)
*/ */
export const useServerEventListener = <T extends SSEEvent["kind"]>( export const useServerEventListener = <T extends SSEEvent["kind"]>(
eventType: T, eventType: T,
listener: EventListener<T>, listener: EventListener<T>,
deps?: React.DependencyList,
) => { ) => {
const { addEventListener } = useSSEContext(); const { addEventListener } = useSSEContext();
const listenerRef = useRef(listener);
useEffect(() => { useEffect(() => {
const removeEventListener = addEventListener(eventType, listener); listenerRef.current = listener;
});
useEffect(() => {
const removeEventListener = addEventListener(eventType, (event) => {
listenerRef.current(event);
});
return () => { return () => {
removeEventListener(); removeEventListener();
}; };
}, [eventType, addEventListener, listener, ...(deps ?? [])]); }, [eventType, addEventListener]);
}; };

View File

@@ -4,7 +4,7 @@ export const configSchema = z.object({
hideNoUserMessageSession: z.boolean().optional().default(true), hideNoUserMessageSession: z.boolean().optional().default(true),
unifySameTitleSession: z.boolean().optional().default(true), unifySameTitleSession: z.boolean().optional().default(true),
enterKeyBehavior: z enterKeyBehavior: z
.enum(["shift-enter-send", "enter-send"]) .enum(["shift-enter-send", "enter-send", "command-enter-send"])
.optional() .optional()
.default("shift-enter-send"), .default("shift-enter-send"),
permissionMode: z permissionMode: z

View File

@@ -5,9 +5,9 @@ import { FileWatcherService } from "../service/events/fileWatcher";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
import { ProjectMetaService } from "../service/project/ProjectMetaService"; import { ProjectMetaService } from "../service/project/ProjectMetaService";
import { ProjectRepository } from "../service/project/ProjectRepository"; import { ProjectRepository } from "../service/project/ProjectRepository";
import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
import { SessionMetaService } from "../service/session/SessionMetaService"; import { SessionMetaService } from "../service/session/SessionMetaService";
import { SessionRepository } from "../service/session/SessionRepository"; import { SessionRepository } from "../service/session/SessionRepository";
import { VirtualConversationDatabase } from "../service/session/VirtualConversationDatabase";
import { InitializeService } from "./initialize"; import { InitializeService } from "./initialize";
describe("InitializeService", () => { describe("InitializeService", () => {

View File

@@ -4,9 +4,9 @@ import { FileWatcherService } from "../service/events/fileWatcher";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
import { ProjectMetaService } from "../service/project/ProjectMetaService"; import { ProjectMetaService } from "../service/project/ProjectMetaService";
import { ProjectRepository } from "../service/project/ProjectRepository"; import { ProjectRepository } from "../service/project/ProjectRepository";
import { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
import { SessionMetaService } from "../service/session/SessionMetaService"; import { SessionMetaService } from "../service/session/SessionMetaService";
import { SessionRepository } from "../service/session/SessionRepository"; import { SessionRepository } from "../service/session/SessionRepository";
import { VirtualConversationDatabase } from "../service/session/VirtualConversationDatabase";
interface InitializeServiceInterface { interface InitializeServiceInterface {
readonly startInitialization: () => Effect.Effect<void>; readonly startInitialization: () => Effect.Effect<void>;

View File

@@ -24,9 +24,9 @@ import { getMcpList } from "../service/mcp/getMcpList";
import { claudeCommandsDirPath } from "../service/paths"; import { claudeCommandsDirPath } from "../service/paths";
import type { ProjectMetaService } from "../service/project/ProjectMetaService"; import type { ProjectMetaService } from "../service/project/ProjectMetaService";
import { ProjectRepository } from "../service/project/ProjectRepository"; import { ProjectRepository } from "../service/project/ProjectRepository";
import type { VirtualConversationDatabase } from "../service/session/PredictSessionsDatabase";
import type { SessionMetaService } from "../service/session/SessionMetaService"; import type { SessionMetaService } from "../service/session/SessionMetaService";
import { SessionRepository } from "../service/session/SessionRepository"; import { SessionRepository } from "../service/session/SessionRepository";
import type { VirtualConversationDatabase } from "../service/session/VirtualConversationDatabase";
import type { HonoAppType } from "./app"; import type { HonoAppType } from "./app";
import { InitializeService } from "./initialize"; import { InitializeService } from "./initialize";
import { configMiddleware } from "./middleware/config.middleware"; import { configMiddleware } from "./middleware/config.middleware";
@@ -184,6 +184,24 @@ export const routes = (app: HonoAppType) =>
}, },
) )
.get("/projects/:projectId/latest-session", async (c) => {
const { projectId } = c.req.param();
const program = Effect.gen(function* () {
const { sessions } = yield* sessionRepository.getSessions(
projectId,
{ maxCount: 1 },
);
return {
latestSession: sessions[0] ?? null,
};
});
const result = await Runtime.runPromise(runtime)(program);
return c.json(result);
})
.get("/projects/:projectId/sessions/:sessionId", async (c) => { .get("/projects/:projectId/sessions/:sessionId", async (c) => {
const { projectId, sessionId } = c.req.param(); const { projectId, sessionId } = c.req.param();

View File

@@ -7,9 +7,9 @@ import { controllablePromise } from "../../../lib/controllablePromise";
import type { Config } from "../../config/config"; import type { Config } from "../../config/config";
import type { InferEffect } from "../../lib/effect/types"; import type { InferEffect } from "../../lib/effect/types";
import { EventBus } from "../events/EventBus"; import { EventBus } from "../events/EventBus";
import { VirtualConversationDatabase } from "../session/PredictSessionsDatabase";
import type { SessionMetaService } from "../session/SessionMetaService"; import type { SessionMetaService } from "../session/SessionMetaService";
import { SessionRepository } from "../session/SessionRepository"; import { SessionRepository } from "../session/SessionRepository";
import { VirtualConversationDatabase } from "../session/VirtualConversationDatabase";
import * as ClaudeCode from "./ClaudeCode"; import * as ClaudeCode from "./ClaudeCode";
import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService"; import { ClaudeCodePermissionService } from "./ClaudeCodePermissionService";
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService"; import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
@@ -209,8 +209,21 @@ const LayerImpl = Effect.gen(function* () {
} }
if ( if (
message.type === "result" && message.type === "assistant" &&
processState.type === "initialized" processState.type === "initialized"
) {
yield* sessionProcessService.toFileCreatedState({
sessionProcessId: processState.def.sessionProcessId,
});
yield* virtualConversationDatabase.deleteVirtualConversations(
message.session_id,
);
}
if (
message.type === "result" &&
processState.type === "file_created"
) { ) {
yield* sessionProcessService.toPausedState({ yield* sessionProcessService.toPausedState({
sessionProcessId: processState.def.sessionProcessId, sessionProcessId: processState.def.sessionProcessId,

View File

@@ -0,0 +1,901 @@
import type { SDKResultMessage, SDKSystemMessage } from "@anthropic-ai/claude-code";
import { Effect, Layer } from "effect";
import { describe, expect, it } from "vitest";
import { EventBus } from "../events/EventBus";
import type { InternalEventDeclaration } from "../events/InternalEventDeclaration";
import { ClaudeCodeSessionProcessService } from "./ClaudeCodeSessionProcessService";
import type { InitMessageContext } from "./createMessageGenerator";
import type * as CCSessionProcess from "./models/CCSessionProcess";
import type * as CCTask from "./models/ClaudeCodeTask";
// Helper function to create mock session process definition
const createMockSessionProcessDef = (
sessionProcessId: string,
projectId = "test-project",
): CCSessionProcess.CCSessionProcessDef => ({
sessionProcessId,
projectId,
cwd: "/test/path",
abortController: new AbortController(),
setNextMessage: () => {},
});
// Helper function to create mock new task definition
const createMockNewTaskDef = (taskId: string): CCTask.NewClaudeCodeTaskDef => ({
type: "new",
taskId,
});
// Helper function to create mock continue task definition
const createMockContinueTaskDef = (
taskId: string,
sessionId: string,
baseSessionId: string,
): CCTask.ContinueClaudeCodeTaskDef => ({
type: "continue",
taskId,
sessionId,
baseSessionId,
});
// Helper function to create mock init context
const createMockInitContext = (sessionId: string): InitMessageContext => ({
initMessage: {
type: "system",
session_id: sessionId,
} as SDKSystemMessage,
});
// Helper function to create mock result message
const createMockResultMessage = (sessionId: string): SDKResultMessage => ({
type: "result",
session_id: sessionId,
result: {},
} as SDKResultMessage);
// Mock EventBus for testing
const MockEventBus = Layer.succeed(EventBus, {
emit: <K extends keyof InternalEventDeclaration>(
_eventName: K,
_event: InternalEventDeclaration[K],
) => Effect.void,
on: <K extends keyof InternalEventDeclaration>(
_eventName: K,
_listener: (event: InternalEventDeclaration[K]) => void,
) => Effect.void,
off: <K extends keyof InternalEventDeclaration>(
_eventName: K,
_listener: (event: InternalEventDeclaration[K]) => void,
) => Effect.void,
});
const TestLayer = Layer.provide(
ClaudeCodeSessionProcessService.Live,
MockEventBus,
);
describe("ClaudeCodeSessionProcessService", () => {
describe("startSessionProcess", () => {
it("can start new session process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
const result = yield* service.startSessionProcess({
sessionDef,
taskDef,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("pending");
expect(result.sessionProcess.def.sessionProcessId).toBe("process-1");
expect(result.task.status).toBe("pending");
expect(result.task.def.taskId).toBe("task-1");
});
it("creates session process with correct task structure", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
const result = yield* service.startSessionProcess({
sessionDef,
taskDef,
});
return { result, taskDef };
});
const { result, taskDef } = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.tasks).toHaveLength(1);
expect(result.sessionProcess.currentTask).toBe(result.task);
expect(result.sessionProcess.currentTask.def).toBe(taskDef);
});
});
describe("getSessionProcess", () => {
it("can retrieve created session process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const process = yield* service.getSessionProcess("process-1");
return process;
});
const process = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(process.def.sessionProcessId).toBe("process-1");
expect(process.type).toBe("pending");
});
it("fails with SessionProcessNotFoundError for non-existent process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip(
service.getSessionProcess("non-existent"),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "SessionProcessNotFoundError",
sessionProcessId: "non-existent",
});
});
});
describe("getSessionProcesses", () => {
it("returns empty array when no processes exist", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const processes = yield* service.getSessionProcesses();
return processes;
});
const processes = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(processes).toHaveLength(0);
});
it("returns all created processes", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef1 = createMockSessionProcessDef("process-1");
const taskDef1 = createMockNewTaskDef("task-1");
const sessionDef2 = createMockSessionProcessDef("process-2");
const taskDef2 = createMockNewTaskDef("task-2");
yield* service.startSessionProcess({
sessionDef: sessionDef1,
taskDef: taskDef1,
});
yield* service.startSessionProcess({
sessionDef: sessionDef2,
taskDef: taskDef2,
});
const processes = yield* service.getSessionProcesses();
return processes;
});
const processes = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(processes).toHaveLength(2);
expect(processes.map((p) => p.def.sessionProcessId)).toEqual(
expect.arrayContaining(["process-1", "process-2"]),
);
});
});
describe("continueSessionProcess", () => {
it("can continue paused session process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
// Start and progress to paused state
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
// Continue the paused process
const continueTaskDef = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const result = yield* service.continueSessionProcess({
sessionProcessId: "process-1",
taskDef: continueTaskDef,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("pending");
expect(result.task.def.taskId).toBe("task-2");
expect(result.sessionProcess.tasks).toHaveLength(2);
});
it("fails with SessionProcessNotPausedError when process is not paused", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const continueTaskDef = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const result = yield* Effect.flip(
service.continueSessionProcess({
sessionProcessId: "process-1",
taskDef: continueTaskDef,
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "SessionProcessNotPausedError",
sessionProcessId: "process-1",
});
});
it("fails with SessionProcessNotFoundError for non-existent process", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const continueTaskDef = createMockContinueTaskDef(
"task-1",
"session-1",
"session-1",
);
const result = yield* Effect.flip(
service.continueSessionProcess({
sessionProcessId: "non-existent",
taskDef: continueTaskDef,
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "SessionProcessNotFoundError",
sessionProcessId: "non-existent",
});
});
});
describe("toNotInitializedState", () => {
it("can transition from pending to not_initialized", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const result = yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("not_initialized");
expect(result.sessionProcess.rawUserMessage).toBe("test message");
expect(result.task.status).toBe("running");
});
it("fails with IllegalStateChangeError when not in pending state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
// Try to transition again
const result = yield* Effect.flip(
service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message 2",
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "IllegalStateChangeError",
from: "not_initialized",
to: "not_initialized",
});
});
});
describe("toInitializedState", () => {
it("can transition from not_initialized to initialized", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
const initContext = createMockInitContext("session-1");
const result = yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("initialized");
expect(result.sessionProcess.sessionId).toBe("session-1");
expect(result.sessionProcess.initContext).toBeDefined();
});
it("fails with IllegalStateChangeError when not in not_initialized state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const initContext = createMockInitContext("session-1");
const result = yield* Effect.flip(
service.toInitializedState({
sessionProcessId: "process-1",
initContext,
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "IllegalStateChangeError",
from: "pending",
to: "initialized",
});
});
});
describe("toPausedState", () => {
it("can transition from file_created to paused", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
const resultMessage = createMockResultMessage("session-1");
const result = yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("paused");
expect(result.sessionProcess.sessionId).toBe("session-1");
});
it("marks current task as completed", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
const process = yield* service.getSessionProcess("process-1");
return process;
});
const process = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
const completedTask = process.tasks.find((t) => t.def.taskId === "task-1");
expect(completedTask?.status).toBe("completed");
if (completedTask?.status === "completed") {
expect(completedTask.sessionId).toBe("session-1");
}
});
it("fails with IllegalStateChangeError when not in file_created state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const result = yield* Effect.flip(
service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
}),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "IllegalStateChangeError",
from: "pending",
to: "paused",
});
});
});
describe("toCompletedState", () => {
it("can transition to completed state from any state", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
const result = yield* service.toCompletedState({
sessionProcessId: "process-1",
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("completed");
});
it("marks current task as completed when no error", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
const result = yield* service.toCompletedState({
sessionProcessId: "process-1",
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.task?.status).toBe("completed");
});
it("marks current task as failed when error is provided", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
const error = new Error("Test error");
const result = yield* service.toCompletedState({
sessionProcessId: "process-1",
error,
});
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.task?.status).toBe("failed");
if (result.task?.status === "failed") {
expect(result.task.error).toBeInstanceOf(Error);
}
});
});
describe("getTask", () => {
it("can retrieve task by taskId", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef,
});
const result = yield* service.getTask("task-1");
return result;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.task.def.taskId).toBe("task-1");
expect(result.sessionProcess.def.sessionProcessId).toBe("process-1");
});
it("fails with TaskNotFoundError for non-existent task", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip(
service.getTask("non-existent-task"),
);
return result;
});
const error = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(error).toMatchObject({
_tag: "TaskNotFoundError",
taskId: "non-existent-task",
});
});
});
describe("state transitions flow", () => {
it("can complete full lifecycle: pending -> not_initialized -> initialized -> file_created -> paused", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef = createMockNewTaskDef("task-1");
const startResult = yield* service.startSessionProcess({
sessionDef,
taskDef,
});
expect(startResult.sessionProcess.type).toBe("pending");
const notInitResult = yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message",
});
expect(notInitResult.sessionProcess.type).toBe("not_initialized");
const initResult = yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
expect(initResult.sessionProcess.type).toBe("initialized");
const fileCreatedResult = yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
expect(fileCreatedResult.sessionProcess.type).toBe("file_created");
const pausedResult = yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
expect(pausedResult.sessionProcess.type).toBe("paused");
return pausedResult;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("paused");
expect(result.sessionProcess.sessionId).toBe("session-1");
});
it("can continue paused process and complete another task", async () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
// First task lifecycle
const sessionDef = createMockSessionProcessDef("process-1");
const taskDef1 = createMockNewTaskDef("task-1");
yield* service.startSessionProcess({
sessionDef,
taskDef: taskDef1,
});
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message 1",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
// Continue with second task
const taskDef2 = createMockContinueTaskDef("task-2", "session-1", "session-1");
const continueResult = yield* service.continueSessionProcess({
sessionProcessId: "process-1",
taskDef: taskDef2,
});
expect(continueResult.sessionProcess.type).toBe("pending");
yield* service.toNotInitializedState({
sessionProcessId: "process-1",
rawUserMessage: "test message 2",
});
yield* service.toInitializedState({
sessionProcessId: "process-1",
initContext: createMockInitContext("session-1"),
});
yield* service.toFileCreatedState({
sessionProcessId: "process-1",
});
const finalResult = yield* service.toPausedState({
sessionProcessId: "process-1",
resultMessage: createMockResultMessage("session-1"),
});
return finalResult;
});
const result = await Effect.runPromise(
program.pipe(Effect.provide(TestLayer)),
);
expect(result.sessionProcess.type).toBe("paused");
expect(result.sessionProcess.tasks).toHaveLength(2);
expect(
result.sessionProcess.tasks.filter((t) => t.status === "completed"),
).toHaveLength(2);
});
});
});

View File

@@ -189,6 +189,7 @@ const LayerImpl = Effect.gen(function* () {
const targetProcess = processes.find( const targetProcess = processes.find(
(p) => p.def.sessionProcessId === sessionProcessId, (p) => p.def.sessionProcessId === sessionProcessId,
); );
const currentStatus = targetProcess?.type;
const updatedProcesses = processes.map((p) => const updatedProcesses = processes.map((p) =>
p.def.sessionProcessId === sessionProcessId ? nextState : p, p.def.sessionProcessId === sessionProcessId ? nextState : p,
@@ -196,7 +197,7 @@ const LayerImpl = Effect.gen(function* () {
yield* Ref.set(processesRef, updatedProcesses); yield* Ref.set(processesRef, updatedProcesses);
if (targetProcess?.type !== nextState.type) { if (currentStatus !== nextState.type) {
yield* eventBus.emit("sessionProcessChanged", { yield* eventBus.emit("sessionProcessChanged", {
processes: updatedProcesses processes: updatedProcesses
.filter(CCSessionProcess.isPublic) .filter(CCSessionProcess.isPublic)
@@ -331,6 +332,40 @@ const LayerImpl = Effect.gen(function* () {
}); });
}; };
const toFileCreatedState = (options: { sessionProcessId: string }) => {
const { sessionProcessId } = options;
return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId);
if (currentProcess.type !== "initialized") {
return yield* Effect.fail(
new IllegalStateChangeError({
from: currentProcess.type,
to: "file_created",
}),
);
}
const newProcess = yield* dangerouslyChangeProcessState({
sessionProcessId,
nextState: {
type: "file_created",
def: currentProcess.def,
tasks: currentProcess.tasks,
currentTask: currentProcess.currentTask,
sessionId: currentProcess.sessionId,
rawUserMessage: currentProcess.rawUserMessage,
initContext: currentProcess.initContext,
},
});
return {
sessionProcess: newProcess,
};
});
};
const toPausedState = (options: { const toPausedState = (options: {
sessionProcessId: string; sessionProcessId: string;
resultMessage: SDKResultMessage; resultMessage: SDKResultMessage;
@@ -339,7 +374,7 @@ const LayerImpl = Effect.gen(function* () {
return Effect.gen(function* () { return Effect.gen(function* () {
const currentProcess = yield* getSessionProcess(sessionProcessId); const currentProcess = yield* getSessionProcess(sessionProcessId);
if (currentProcess.type !== "initialized") { if (currentProcess.type !== "file_created") {
return yield* Effect.fail( return yield* Effect.fail(
new IllegalStateChangeError({ new IllegalStateChangeError({
from: currentProcess.type, from: currentProcess.type,
@@ -387,7 +422,8 @@ const LayerImpl = Effect.gen(function* () {
const currentTask = const currentTask =
currentProcess.type === "not_initialized" || currentProcess.type === "not_initialized" ||
currentProcess.type === "initialized" currentProcess.type === "initialized" ||
currentProcess.type === "file_created"
? currentProcess.currentTask ? currentProcess.currentTask
: undefined; : undefined;
@@ -442,6 +478,7 @@ const LayerImpl = Effect.gen(function* () {
continueSessionProcess, continueSessionProcess,
toNotInitializedState, toNotInitializedState,
toInitializedState, toInitializedState,
toFileCreatedState,
toPausedState, toPausedState,
toCompletedState, toCompletedState,
dangerouslyChangeProcessState, dangerouslyChangeProcessState,

View File

@@ -39,6 +39,14 @@ export type CCSessionProcessInitializedState = CCSessionProcessStateBase & {
initContext: InitMessageContext; initContext: InitMessageContext;
}; };
export type CCSessionProcessFileCreatedState = CCSessionProcessStateBase & {
type: "file_created" /* ファイルが作成された状態 */;
sessionId: string;
currentTask: CCTask.RunningClaudeCodeTaskState;
rawUserMessage: string;
initContext: InitMessageContext;
};
export type CCSessionProcessPausedState = CCSessionProcessStateBase & { export type CCSessionProcessPausedState = CCSessionProcessStateBase & {
type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */; type: "paused" /* タスクが完了し、次のタスクを受け付け可能 */;
sessionId: string; sessionId: string;
@@ -51,6 +59,7 @@ export type CCSessionProcessCompletedState = CCSessionProcessStateBase & {
export type CCSessionProcessStatePublic = export type CCSessionProcessStatePublic =
| CCSessionProcessInitializedState | CCSessionProcessInitializedState
| CCSessionProcessFileCreatedState
| CCSessionProcessPausedState; | CCSessionProcessPausedState;
export type CCSessionProcessState = export type CCSessionProcessState =
@@ -62,7 +71,11 @@ export type CCSessionProcessState =
export const isPublic = ( export const isPublic = (
process: CCSessionProcessState, process: CCSessionProcessState,
): process is CCSessionProcessStatePublic => { ): process is CCSessionProcessStatePublic => {
return process.type === "initialized" || process.type === "paused"; return (
process.type === "initialized" ||
process.type === "file_created" ||
process.type === "paused"
);
}; };
export const getAliveTasks = ( export const getAliveTasks = (

View File

@@ -28,6 +28,9 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
const projectWatchersRef = yield* Ref.make<Map<string, FSWatcher>>( const projectWatchersRef = yield* Ref.make<Map<string, FSWatcher>>(
new Map(), new Map(),
); );
const debounceTimersRef = yield* Ref.make<
Map<string, ReturnType<typeof setTimeout>>
>(new Map());
const startWatching = (): Effect.Effect<void> => const startWatching = (): Effect.Effect<void> =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -52,17 +55,42 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
if (!groups.success) return; if (!groups.success) return;
const { projectId, sessionId } = groups.data; const { projectId, sessionId } = groups.data;
const debounceKey = `${projectId}/${sessionId}`;
Effect.runFork( Effect.runPromise(
eventBus.emit("sessionChanged", { Effect.gen(function* () {
projectId, const timers = yield* Ref.get(debounceTimersRef);
sessionId, const existingTimer = timers.get(debounceKey);
}), if (existingTimer) {
); clearTimeout(existingTimer);
}
Effect.runFork( const newTimer = setTimeout(() => {
eventBus.emit("sessionListChanged", { Effect.runFork(
projectId, eventBus.emit("sessionChanged", {
projectId,
sessionId,
}),
);
Effect.runFork(
eventBus.emit("sessionListChanged", {
projectId,
}),
);
Effect.runPromise(
Effect.gen(function* () {
const currentTimers =
yield* Ref.get(debounceTimersRef);
currentTimers.delete(debounceKey);
yield* Ref.set(debounceTimersRef, currentTimers);
}),
);
}, 300);
timers.set(debounceKey, newTimer);
yield* Ref.set(debounceTimersRef, timers);
}), }),
); );
}, },
@@ -85,6 +113,12 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
const stop = (): Effect.Effect<void> => const stop = (): Effect.Effect<void> =>
Effect.gen(function* () { Effect.gen(function* () {
const timers = yield* Ref.get(debounceTimersRef);
for (const [, timer] of timers) {
clearTimeout(timer);
}
yield* Ref.set(debounceTimersRef, new Map());
const watcher = yield* Ref.get(watcherRef); const watcher = yield* Ref.get(watcherRef);
if (watcher) { if (watcher) {
yield* Effect.sync(() => watcher.close()); yield* Effect.sync(() => watcher.close());

View File

@@ -0,0 +1,369 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { branchExists, getBranches, getCurrentBranch } from "./getBranches";
import * as utils from "./utils";
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof utils>();
return {
...actual,
executeGitCommand: vi.fn(),
};
});
describe("getBranches", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("正常系", () => {
it("ブランチ一覧を取得できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `* main abc1234 [origin/main: ahead 1] Latest commit
remotes/origin/main abc1234 Latest commit
feature def5678 [origin/feature] Feature commit`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0]).toEqual({
name: "main",
current: true,
remote: "origin/main",
commit: "abc1234",
ahead: 1,
behind: undefined,
});
expect(result.data[1]).toEqual({
name: "feature",
current: false,
remote: "origin/feature",
commit: "def5678",
ahead: undefined,
behind: undefined,
});
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["branch", "-vv", "--all"],
mockCwd,
);
});
it("ahead/behindの両方を持つブランチを処理できる", async () => {
const mockCwd = "/test/repo";
const mockOutput =
"* main abc1234 [origin/main: ahead 2, behind 3] Commit message";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(1);
expect(result.data[0]).toEqual({
name: "main",
current: true,
remote: "origin/main",
commit: "abc1234",
ahead: 2,
behind: 3,
});
}
});
it("リモートトラッキングブランチを除外する", async () => {
const mockCwd = "/test/repo";
const mockOutput = `* main abc1234 [origin/main] Latest commit
remotes/origin/main abc1234 Latest commit
feature def5678 Feature commit
remotes/origin/feature def5678 Feature commit`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0]?.name).toBe("main");
expect(result.data[1]?.name).toBe("feature");
}
});
it("空の結果を返す(ブランチがない場合)", async () => {
const mockCwd = "/test/repo";
const mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(0);
}
});
it("不正な形式の行をスキップする", async () => {
const mockCwd = "/test/repo";
const mockOutput = `* main abc1234 [origin/main] Latest commit
invalid line
feature def5678 Feature commit`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0]?.name).toBe("main");
expect(result.data[1]?.name).toBe("feature");
}
});
});
describe("エラー系", () => {
it("ディレクトリが存在しない場合", async () => {
const mockCwd = "/nonexistent/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${mockCwd}`,
command: "git branch -vv --all",
},
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Directory does not exist");
}
});
it("Gitリポジトリでない場合", async () => {
const mockCwd = "/test/not-a-repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${mockCwd}`,
command: "git branch -vv --all",
},
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Not a git repository");
}
});
it("Gitコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "COMMAND_FAILED",
message: "Command failed",
command: "git branch",
stderr: "fatal: not a git repository",
},
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("COMMAND_FAILED");
expect(result.error.message).toBe("Command failed");
}
});
});
describe("エッジケース", () => {
it("特殊文字を含むブランチ名を処理できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `* feature/special-chars_123 abc1234 Commit
feature/日本語ブランチ def5678 日本語コミット`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getBranches(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0]?.name).toBe("feature/special-chars_123");
expect(result.data[1]?.name).toBe("feature/日本語ブランチ");
}
});
});
});
describe("getCurrentBranch", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("現在のブランチ名を取得できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = "main\n";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCurrentBranch(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe("main");
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["branch", "--show-current"],
mockCwd,
);
});
it("detached HEAD状態の場合はエラーを返す", async () => {
const mockCwd = "/test/repo";
const mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCurrentBranch(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("COMMAND_FAILED");
expect(result.error.message).toContain("detached HEAD");
}
});
it("Gitリポジトリでない場合", async () => {
const mockCwd = "/test/not-a-repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${mockCwd}`,
command: "git branch --show-current",
},
});
const result = await getCurrentBranch(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
}
});
});
describe("branchExists", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("ブランチが存在する場合trueを返す", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: "abc1234\n",
});
const result = await branchExists(mockCwd, "main");
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["rev-parse", "--verify", "main"],
mockCwd,
);
});
it("ブランチが存在しない場合falseを返す", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "COMMAND_FAILED",
message: "Command failed",
command: "git rev-parse --verify nonexistent",
stderr: "fatal: Needed a single revision",
},
});
const result = await branchExists(mockCwd, "nonexistent");
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it("Gitリポジトリでない場合", async () => {
const mockCwd = "/test/not-a-repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${mockCwd}`,
command: "git rev-parse --verify main",
},
});
const result = await branchExists(mockCwd, "main");
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
});

View File

@@ -0,0 +1,250 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getCommits } from "./getCommits";
import * as utils from "./utils";
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof utils>();
return {
...actual,
executeGitCommand: vi.fn(),
};
});
describe("getCommits", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("正常系", () => {
it("コミット一覧を取得できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
sha: "abc123",
message: "feat: add new feature",
author: "John Doe",
date: "2024-01-15 10:30:00 +0900",
});
expect(result.data[1]).toEqual({
sha: "def456",
message: "fix: bug fix",
author: "Jane Smith",
date: "2024-01-14 09:20:00 +0900",
});
expect(result.data[2]).toEqual({
sha: "ghi789",
message: "chore: update deps",
author: "Bob Johnson",
date: "2024-01-13 08:10:00 +0900",
});
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
[
"log",
"--oneline",
"-n",
"20",
"--format=%H|%s|%an|%ad",
"--date=iso",
],
mockCwd,
);
});
it("空の結果を返す(コミットがない場合)", async () => {
const mockCwd = "/test/repo";
const mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(0);
}
});
it("不正な形式の行をスキップする", async () => {
const mockCwd = "/test/repo";
const mockOutput = `abc123|feat: add new feature|John Doe|2024-01-15 10:30:00 +0900
invalid line without enough pipes
def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
||missing data|
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(3);
expect(result.data[0]?.sha).toBe("abc123");
expect(result.data[1]?.sha).toBe("def456");
expect(result.data[2]?.sha).toBe("ghi789");
}
});
});
describe("エラー系", () => {
it("ディレクトリが存在しない場合", async () => {
const mockCwd = "/nonexistent/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${mockCwd}`,
command: "git log --oneline -n 20 --format=%H|%s|%an|%ad --date=iso",
},
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Directory does not exist");
}
});
it("Gitリポジトリでない場合", async () => {
const mockCwd = "/test/not-a-repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${mockCwd}`,
command: "git log --oneline -n 20 --format=%H|%s|%an|%ad --date=iso",
},
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Not a git repository");
}
});
it("Gitコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "COMMAND_FAILED",
message: "Command failed",
command: "git log",
stderr:
"fatal: your current branch 'main' does not have any commits yet",
},
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("COMMAND_FAILED");
expect(result.error.message).toBe("Command failed");
}
});
});
describe("エッジケース", () => {
it("特殊文字を含むコミットメッセージを処理できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `abc123|feat: add "quotes" & <special> chars|Author Name|2024-01-15 10:30:00 +0900
def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data[0]?.message).toBe(
'feat: add "quotes" & <special> chars',
);
expect(result.data[1]?.message).toBe("fix: 日本語メッセージ");
expect(result.data[1]?.author).toBe("日本語 著者");
}
});
it("空白を含むパスでも正常に動作する", async () => {
const mockCwd = "/test/my repo with spaces";
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(1);
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
expect.any(Array),
mockCwd,
);
});
it("空行やスペースのみの行をスキップする", async () => {
const mockCwd = "/test/repo";
const mockOutput = `abc123|feat: add feature|Author|2024-01-15 10:30:00 +0900
def456|fix: bug|Author|2024-01-14 09:20:00 +0900
`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getCommits(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
}
});
});
});

View File

@@ -0,0 +1,521 @@
import { readFile } from "node:fs/promises";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { compareBranches, getDiff } from "./getDiff";
import * as utils from "./utils";
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof utils>();
return {
...actual,
executeGitCommand: vi.fn(),
};
});
vi.mock("node:fs/promises");
describe("getDiff", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("正常系", () => {
it("2つのブランチ間のdiffを取得できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = `5\t2\tsrc/file1.ts
10\t0\tsrc/file2.ts`;
const mockDiffOutput = `diff --git a/src/file1.ts b/src/file1.ts
index abc123..def456 100644
--- a/src/file1.ts
+++ b/src/file1.ts
@@ -1,5 +1,8 @@
function hello() {
- console.log("old");
+ console.log("new");
+ console.log("added line 1");
+ console.log("added line 2");
}
diff --git a/src/file2.ts b/src/file2.ts
new file mode 100644
index 0000000..ghi789
--- /dev/null
+++ b/src/file2.ts
@@ -0,0 +1,10 @@
+export const newFile = true;`;
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(2);
expect(result.data.files[0]?.filePath).toBe("src/file1.ts");
expect(result.data.files[0]?.status).toBe("modified");
expect(result.data.files[0]?.additions).toBe(5);
expect(result.data.files[0]?.deletions).toBe(2);
expect(result.data.files[1]?.filePath).toBe("src/file2.ts");
expect(result.data.files[1]?.status).toBe("added");
expect(result.data.files[1]?.additions).toBe(10);
expect(result.data.files[1]?.deletions).toBe(0);
expect(result.data.summary.totalFiles).toBe(2);
expect(result.data.summary.totalAdditions).toBe(15);
expect(result.data.summary.totalDeletions).toBe(2);
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["diff", "--numstat", "main", "feature"],
mockCwd,
);
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["diff", "--unified=5", "main", "feature"],
mockCwd,
);
});
it("HEADとworking directoryの比較ができる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:HEAD";
const toRef = "compare:working";
const mockNumstatOutput = `3\t1\tsrc/modified.ts`;
const mockDiffOutput = `diff --git a/src/modified.ts b/src/modified.ts
index abc123..def456 100644
--- a/src/modified.ts
+++ b/src/modified.ts
@@ -1,3 +1,5 @@
const value = 1;`;
const mockStatusOutput = `## main
M src/modified.ts
?? src/untracked.ts`;
vi.mocked(readFile).mockResolvedValue("line1\nline2\nline3");
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockStatusOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
// modified file + untracked file
expect(result.data.files.length).toBeGreaterThanOrEqual(1);
const modifiedFile = result.data.files.find(
(f) => f.filePath === "src/modified.ts",
);
expect(modifiedFile).toBeDefined();
expect(modifiedFile?.status).toBe("modified");
}
});
it("同一refの場合は空の結果を返す", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:main";
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(0);
expect(result.data.diffs).toHaveLength(0);
expect(result.data.summary.totalFiles).toBe(0);
expect(result.data.summary.totalAdditions).toBe(0);
expect(result.data.summary.totalDeletions).toBe(0);
}
expect(utils.executeGitCommand).not.toHaveBeenCalled();
});
it.skip("削除されたファイルを処理できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = `0\t10\tsrc/deleted.ts`;
const mockDiffOutput = `diff --git a/src/deleted.ts b/src/deleted.ts
deleted file mode 100644
index abc123..0000000 100644
--- a/src/deleted.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-deleted line 1
-deleted line 2
-deleted line 3
-deleted line 4
-deleted line 5
-deleted line 6
-deleted line 7
-deleted line 8
-deleted line 9
-deleted line 10`;
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(1);
expect(result.data.files[0]?.filePath).toBe("src/deleted.ts");
expect(result.data.files[0]?.status).toBe("deleted");
expect(result.data.files[0]?.additions).toBe(0);
expect(result.data.files[0]?.deletions).toBe(10);
}
});
it.skip("名前変更されたファイルを処理できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = `0\t0\tnew-name.ts`;
const mockDiffOutput = `diff --git a/old-name.ts b/new-name.ts
similarity index 100%
rename from old-name.ts
rename to new-name.ts
index abc123..abc123 100644`;
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(1);
expect(result.data.files[0]?.status).toBe("renamed");
expect(result.data.files[0]?.filePath).toBe("new-name.ts");
expect(result.data.files[0]?.oldPath).toBe("old-name.ts");
}
});
it("空のdiffを処理できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = "";
const mockDiffOutput = "";
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(0);
expect(result.data.diffs).toHaveLength(0);
expect(result.data.summary.totalFiles).toBe(0);
}
});
});
describe("エラー系", () => {
it("ディレクトリが存在しない場合", async () => {
const mockCwd = "/nonexistent/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${mockCwd}`,
command: "git diff --numstat main feature",
},
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Directory does not exist");
}
});
it("Gitリポジトリでない場合", async () => {
const mockCwd = "/test/not-a-repo";
const fromRef = "base:main";
const toRef = "compare:feature";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${mockCwd}`,
command: "git diff --numstat main feature",
},
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Not a git repository");
}
});
it("ブランチが見つからない場合", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:nonexistent";
const toRef = "compare:feature";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "BRANCH_NOT_FOUND",
message: "Branch or commit not found",
command: "git diff --numstat nonexistent feature",
stderr:
"fatal: ambiguous argument 'nonexistent': unknown revision or path not in the working tree.",
},
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("BRANCH_NOT_FOUND");
expect(result.error.message).toBe("Branch or commit not found");
}
});
it("numstatコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "COMMAND_FAILED",
message: "Command failed",
command: "git diff --numstat main feature",
stderr: "fatal: bad revision",
},
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("COMMAND_FAILED");
}
});
it("無効なfromRefの場合", async () => {
const mockCwd = "/test/repo";
const fromRef = "invalidref";
const toRef = "compare:feature";
await expect(getDiff(mockCwd, fromRef, toRef)).rejects.toThrow(
"Invalid ref text",
);
});
});
describe("エッジケース", () => {
it("特殊文字を含むファイル名を処理できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = `5\t2\tsrc/file with spaces.ts
3\t1\tsrc/日本語ファイル.ts`;
const mockDiffOutput = `diff --git a/src/file with spaces.ts b/src/file with spaces.ts
index abc123..def456 100644
--- a/src/file with spaces.ts
+++ b/src/file with spaces.ts
@@ -1,3 +1,5 @@
content
diff --git a/src/日本語ファイル.ts b/src/日本語ファイル.ts
index abc123..def456 100644
--- a/src/日本語ファイル.ts
+++ b/src/日本語ファイル.ts
@@ -1,2 +1,3 @@
content`;
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(2);
expect(result.data.files[0]?.filePath).toBe("src/file with spaces.ts");
expect(result.data.files[1]?.filePath).toBe("src/日本語ファイル.ts");
}
});
it("バイナリファイルの変更を処理できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = `-\t-\timage.png`;
const mockDiffOutput = `diff --git a/image.png b/image.png
index abc123..def456 100644
Binary files a/image.png and b/image.png differ`;
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(1);
expect(result.data.files[0]?.filePath).toBe("image.png");
expect(result.data.files[0]?.additions).toBe(0);
expect(result.data.files[0]?.deletions).toBe(0);
}
});
it("大量のファイル変更を処理できる", async () => {
const mockCwd = "/test/repo";
const fromRef = "base:main";
const toRef = "compare:feature";
const mockNumstatOutput = Array.from(
{ length: 100 },
(_, i) => `1\t1\tfile${i}.ts`,
).join("\n");
const mockDiffOutput = Array.from(
{ length: 100 },
(_, i) => `diff --git a/file${i}.ts b/file${i}.ts
index abc123..def456 100644
--- a/file${i}.ts
+++ b/file${i}.ts
@@ -1 +1 @@
-old
+new`,
).join("\n");
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await getDiff(mockCwd, fromRef, toRef);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(100);
expect(result.data.summary.totalFiles).toBe(100);
expect(result.data.summary.totalAdditions).toBe(100);
expect(result.data.summary.totalDeletions).toBe(100);
}
});
});
});
describe("compareBranches", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("getDiffのショートハンドとして機能する", async () => {
const mockCwd = "/test/repo";
const baseBranch = "base:main";
const targetBranch = "compare:feature";
const mockNumstatOutput = `5\t2\tfile.ts`;
const mockDiffOutput = `diff --git a/file.ts b/file.ts
index abc123..def456 100644
--- a/file.ts
+++ b/file.ts
@@ -1,2 +1,5 @@
content`;
vi.mocked(utils.executeGitCommand)
.mockResolvedValueOnce({
success: true,
data: mockNumstatOutput,
})
.mockResolvedValueOnce({
success: true,
data: mockDiffOutput,
});
const result = await compareBranches(mockCwd, baseBranch, targetBranch);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.files).toHaveLength(1);
expect(result.data.files[0]?.filePath).toBe("file.ts");
}
});
});

View File

@@ -0,0 +1,351 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getStatus,
getUncommittedChanges,
isWorkingDirectoryClean,
} from "./getStatus";
import * as utils from "./utils";
vi.mock("./utils", async (importOriginal) => {
const actual = await importOriginal<typeof utils>();
return {
...actual,
executeGitCommand: vi.fn(),
};
});
describe("getStatus", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("正常系", () => {
it("Gitステータス情報を取得できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main...origin/main [ahead 2, behind 1]
M staged-modified.ts
M unstaged-modified.ts
A staged-added.ts
?? untracked-file.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.branch).toBe("main");
expect(result.data.ahead).toBe(2);
expect(result.data.behind).toBe(1);
expect(result.data.staged).toHaveLength(2);
expect(result.data.staged[0]?.filePath).toBe("staged-modified.ts");
expect(result.data.staged[0]?.status).toBe("modified");
expect(result.data.staged[1]?.filePath).toBe("staged-added.ts");
expect(result.data.staged[1]?.status).toBe("added");
expect(result.data.unstaged).toHaveLength(1);
expect(result.data.unstaged[0]?.filePath).toBe("unstaged-modified.ts");
expect(result.data.unstaged[0]?.status).toBe("modified");
expect(result.data.untracked).toEqual(["untracked-file.ts"]);
expect(result.data.conflicted).toHaveLength(0);
}
expect(utils.executeGitCommand).toHaveBeenCalledWith(
["status", "--porcelain=v1", "-b"],
mockCwd,
);
});
it("名前変更されたファイルを処理できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
R old-name.ts -> new-name.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.staged).toHaveLength(1);
expect(result.data.staged[0]?.filePath).toBe("new-name.ts");
expect(result.data.staged[0]?.oldPath).toBe("old-name.ts");
expect(result.data.staged[0]?.status).toBe("renamed");
}
});
it("コンフリクトファイルを検出できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
UU conflicted-file.ts
MM both-modified.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.conflicted).toEqual([
"conflicted-file.ts",
"both-modified.ts",
]);
expect(result.data.staged).toHaveLength(0);
expect(result.data.unstaged).toHaveLength(0);
}
});
it("空のリポジトリ(クリーンな状態)を処理できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = "## main";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.branch).toBe("main");
expect(result.data.ahead).toBe(0);
expect(result.data.behind).toBe(0);
expect(result.data.staged).toHaveLength(0);
expect(result.data.unstaged).toHaveLength(0);
expect(result.data.untracked).toHaveLength(0);
expect(result.data.conflicted).toHaveLength(0);
}
});
it("ブランチがupstreamを持たない場合", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## feature-branch
M file.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.branch).toBe("feature-branch");
expect(result.data.ahead).toBe(0);
expect(result.data.behind).toBe(0);
}
});
});
describe("エラー系", () => {
it("ディレクトリが存在しない場合", async () => {
const mockCwd = "/nonexistent/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Directory does not exist: ${mockCwd}`,
command: "git status --porcelain=v1 -b",
},
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Directory does not exist");
}
});
it("Gitリポジトリでない場合", async () => {
const mockCwd = "/test/not-a-repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "NOT_A_REPOSITORY",
message: `Not a git repository: ${mockCwd}`,
command: "git status --porcelain=v1 -b",
},
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("NOT_A_REPOSITORY");
expect(result.error.message).toContain("Not a git repository");
}
});
it("Gitコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
code: "COMMAND_FAILED",
message: "Command failed",
command: "git status",
stderr: "fatal: not a git repository",
},
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe("COMMAND_FAILED");
expect(result.error.message).toBe("Command failed");
}
});
});
describe("エッジケース", () => {
it("特殊文字を含むファイル名を処理できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
M file with spaces.ts
A 日本語ファイル.ts
?? special@#$%chars.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getStatus(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.staged[0]?.filePath).toBe("file with spaces.ts");
expect(result.data.staged[1]?.filePath).toBe("日本語ファイル.ts");
expect(result.data.untracked).toEqual(["special@#$%chars.ts"]);
}
});
});
});
describe("getUncommittedChanges", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("stagedとunstagedの両方の変更を取得できる", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
M staged-file.ts
M unstaged-file.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getUncommittedChanges(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toHaveLength(2);
expect(result.data.some((f) => f.filePath === "staged-file.ts")).toBe(
true,
);
expect(result.data.some((f) => f.filePath === "unstaged-file.ts")).toBe(
true,
);
}
});
it("重複するファイルを削除する", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
MM both-changed.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await getUncommittedChanges(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
// Conflictとして処理されるため空になる
expect(result.data).toHaveLength(0);
}
});
});
describe("isWorkingDirectoryClean", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("クリーンな作業ディレクトリでtrueを返す", async () => {
const mockCwd = "/test/repo";
const mockOutput = "## main";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await isWorkingDirectoryClean(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it("変更がある場合falseを返す", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
M modified-file.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await isWorkingDirectoryClean(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it("未追跡ファイルがある場合falseを返す", async () => {
const mockCwd = "/test/repo";
const mockOutput = `## main
?? untracked-file.ts`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
});
const result = await isWorkingDirectoryClean(mockCwd);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
});

View File

@@ -0,0 +1,301 @@
import { parseCommandXml } from "./parseCommandXml";
describe("parseCommandXml", () => {
describe("command parsing", () => {
it("parses command-name only", () => {
const input = "<command-name>git status</command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "git status",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("parses command-name with command-args", () => {
const input =
"<command-name>git commit</command-name><command-args>-m 'test'</command-args>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "git commit",
commandArgs: "-m 'test'",
commandMessage: undefined,
});
});
it("parses command-name with command-message", () => {
const input =
"<command-name>ls</command-name><command-message>Listing files</command-message>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "ls",
commandArgs: undefined,
commandMessage: "Listing files",
});
});
it("parses all command tags together", () => {
const input =
"<command-name>npm install</command-name><command-args>--save-dev vitest</command-args><command-message>Installing test dependencies</command-message>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "npm install",
commandArgs: "--save-dev vitest",
commandMessage: "Installing test dependencies",
});
});
it("parses command tags with whitespace in content", () => {
const input =
"<command-name>\n git status \n</command-name><command-args> --short </command-args>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "\n git status \n",
commandArgs: " --short ",
commandMessage: undefined,
});
});
it("parses command tags in different order", () => {
const input =
"<command-message>Test message</command-message><command-args>-v</command-args><command-name>test command</command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "test command",
commandArgs: "-v",
commandMessage: "Test message",
});
});
});
describe("local-command parsing", () => {
it("parses local-command-stdout", () => {
const input = "<local-command-stdout>output text</local-command-stdout>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "local-command",
stdout: "output text",
});
});
it("parses local-command-stdout with multiline content", () => {
const input =
"<local-command-stdout>line1\nline2\nline3</local-command-stdout>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "local-command",
stdout: "line1\nline2\nline3",
});
});
it("parses local-command-stdout with whitespace", () => {
const input =
"<local-command-stdout> \n output with spaces \n </local-command-stdout>";
const result = parseCommandXml(input);
// The regex pattern preserves all whitespace in content
expect(result).toEqual({
kind: "local-command",
stdout: " \n output with spaces \n ",
});
});
});
describe("priority: command over local-command", () => {
it("returns command when both command and local-command tags exist", () => {
const input =
"<command-name>test</command-name><local-command-stdout>output</local-command-stdout>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("test");
}
});
});
describe("fallback to text", () => {
it("returns text when no matching tags found", () => {
const input = "just plain text";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "just plain text",
});
});
it("returns text when tags are not closed properly", () => {
const input = "<command-name>incomplete";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "<command-name>incomplete",
});
});
it("returns text when tags are mismatched", () => {
const input = "<command-name>test</different-tag>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "<command-name>test</different-tag>",
});
});
it("returns text with empty string", () => {
const input = "";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "",
});
});
it("returns text with only unrecognized tags", () => {
const input = "<unknown-tag>content</unknown-tag>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "text",
content: "<unknown-tag>content</unknown-tag>",
});
});
});
describe("edge cases", () => {
it("handles multiple same tags (uses first match)", () => {
const input =
"<command-name>first</command-name><command-name>second</command-name>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("first");
}
});
it("handles empty tag content", () => {
const input = "<command-name></command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("handles tags with special characters in content", () => {
const input =
"<command-name>git commit -m 'test &amp; demo'</command-name>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("git commit -m 'test &amp; demo'");
}
});
it("does not match nested tags (regex limitation)", () => {
const input = "<command-name><nested>inner</nested>outer</command-name>";
const result = parseCommandXml(input);
// The regex won't match properly nested tags due to [^<]* pattern
expect(result.kind).toBe("text");
});
it("handles tags with surrounding text", () => {
const input =
"Some text before <command-name>test</command-name> and after";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "test",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("handles newlines between tags", () => {
const input =
"<command-name>test</command-name>\n\n<command-args>arg</command-args>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "test",
commandArgs: "arg",
commandMessage: undefined,
});
});
it("handles very long content", () => {
const longContent = "x".repeat(10000);
const input = `<command-name>${longContent}</command-name>`;
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe(longContent);
}
});
it("handles tags with attributes (not matched)", () => {
const input = '<command-name attr="value">test</command-name>';
const result = parseCommandXml(input);
// Tags with attributes won't match because regex expects <tag> not <tag attr="...">
expect(result.kind).toBe("text");
});
it("handles self-closing tags (not matched)", () => {
const input = "<command-name />";
const result = parseCommandXml(input);
expect(result.kind).toBe("text");
});
it("handles Unicode content", () => {
const input = "<command-name>テスト コマンド 🚀</command-name>";
const result = parseCommandXml(input);
expect(result).toEqual({
kind: "command",
commandName: "テスト コマンド 🚀",
commandArgs: undefined,
commandMessage: undefined,
});
});
it("handles mixed content with multiple tag types", () => {
const input =
"Some text <command-name>cmd</command-name> more text <unknown>tag</unknown>";
const result = parseCommandXml(input);
expect(result.kind).toBe("command");
if (result.kind === "command") {
expect(result.commandName).toBe("cmd");
}
});
});
});

View File

@@ -0,0 +1,378 @@
import { describe, expect, it } from "vitest";
import { parseJsonl } from "./parseJsonl";
import type { ErrorJsonl } from "./types";
describe("parseJsonl", () => {
describe("正常系: 有効なJSONLをパースできる", () => {
it("単一のUserエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("type", "user");
const entry = result[0];
if (entry && entry.type === "user") {
expect(entry.message.content).toBe("Hello");
}
});
it("単一のSummaryエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "summary",
summary: "This is a summary",
leafUuid: "550e8400-e29b-41d4-a716-446655440003",
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("type", "summary");
const entry = result[0];
if (entry && entry.type === "summary") {
expect(entry.summary).toBe("This is a summary");
}
});
it("複数のエントリをパースできる", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({
type: "summary",
summary: "Test summary",
leafUuid: "550e8400-e29b-41d4-a716-446655440002",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "summary");
});
});
describe("エラー系: 不正なJSON行をErrorJsonlとして返す", () => {
it("無効なJSONを渡すとエラーを投げる", () => {
const jsonl = "invalid json";
// parseJsonl の実装は JSON.parse をそのまま呼び出すため、
// 無効な JSON は例外を投げます
expect(() => parseJsonl(jsonl)).toThrow();
});
it("スキーマに合わないオブジェクトをErrorJsonlとして返す", () => {
const jsonl = JSON.stringify({
type: "unknown",
someField: "value",
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const errorEntry = result[0] as ErrorJsonl;
expect(errorEntry.type).toBe("x-error");
expect(errorEntry.lineNumber).toBe(1);
});
it("必須フィールドが欠けているエントリをErrorJsonlとして返す", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
// timestamp, message などの必須フィールドが欠けている
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const errorEntry = result[0] as ErrorJsonl;
expect(errorEntry.type).toBe("x-error");
expect(errorEntry.lineNumber).toBe(1);
});
it("正常なエントリとエラーエントリを混在して返す", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({ type: "invalid-schema" }),
JSON.stringify({
type: "summary",
summary: "Summary text",
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(3);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "x-error");
expect(result[2]).toHaveProperty("type", "summary");
const errorEntry = result[1] as ErrorJsonl;
expect(errorEntry.lineNumber).toBe(2);
});
});
describe("エッジケース: 空行、トリム、複数エントリ", () => {
it("空文字列を渡すと空配列を返す", () => {
const result = parseJsonl("");
expect(result).toEqual([]);
});
it("空行のみを渡すと空配列を返す", () => {
const result = parseJsonl("\n\n\n");
expect(result).toEqual([]);
});
it("前後の空白をトリムする", () => {
const jsonl = `
${JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
})}
`;
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty("type", "user");
});
it("行間の空行を除外する", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
"",
"",
JSON.stringify({
type: "summary",
summary: "Summary text",
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "summary");
});
it("空白のみの行を除外する", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
" ",
"\t",
JSON.stringify({
type: "summary",
summary: "Summary text",
leafUuid: "550e8400-e29b-41d4-a716-446655440001",
}),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(2);
expect(result[0]).toHaveProperty("type", "user");
expect(result[1]).toHaveProperty("type", "summary");
});
it("多数のエントリを含むJSONLをパースできる", () => {
const entries = Array.from({ length: 100 }, (_, i) => {
return JSON.stringify({
type: "user",
uuid: `550e8400-e29b-41d4-a716-${String(i).padStart(12, "0")}`,
timestamp: new Date(Date.UTC(2024, 0, 1, 0, 0, i)).toISOString(),
message: {
role: "user",
content: `Message ${i}`,
},
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid:
i > 0
? `550e8400-e29b-41d4-a716-${String(i - 1).padStart(12, "0")}`
: null,
});
});
const jsonl = entries.join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(100);
expect(result.every((entry) => entry.type === "user")).toBe(true);
});
});
describe("行番号の正確性", () => {
it("スキーマ検証エラー時の行番号が正確に記録される", () => {
const jsonl = [
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Line 1" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({ type: "invalid", data: "schema error" }),
JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440001",
timestamp: "2024-01-01T00:00:01.000Z",
message: { role: "user", content: "Line 3" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
}),
JSON.stringify({ type: "another-invalid" }),
].join("\n");
const result = parseJsonl(jsonl);
expect(result).toHaveLength(4);
expect((result[1] as ErrorJsonl).lineNumber).toBe(2);
expect((result[1] as ErrorJsonl).type).toBe("x-error");
expect((result[3] as ErrorJsonl).lineNumber).toBe(4);
expect((result[3] as ErrorJsonl).type).toBe("x-error");
});
it("空行フィルタ後の行番号が正確に記録される", () => {
const jsonl = ["", "", JSON.stringify({ type: "invalid-schema" })].join(
"\n",
);
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
// 空行がフィルタされた後のインデックスは0だが、lineNumberは1として記録される
expect((result[0] as ErrorJsonl).lineNumber).toBe(1);
});
});
describe("ConversationSchemaのバリエーション", () => {
it("オプショナルフィールドを含むUserエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: true,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: "550e8400-e29b-41d4-a716-446655440099",
gitBranch: "main",
isMeta: false,
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const entry = result[0];
if (entry && entry.type === "user") {
expect(entry.isSidechain).toBe(true);
expect(entry.parentUuid).toBe("550e8400-e29b-41d4-a716-446655440099");
expect(entry.gitBranch).toBe("main");
}
});
it("nullableフィールドがnullのエントリをパースできる", () => {
const jsonl = JSON.stringify({
type: "user",
uuid: "550e8400-e29b-41d4-a716-446655440000",
timestamp: "2024-01-01T00:00:00.000Z",
message: { role: "user", content: "Hello" },
isSidechain: false,
userType: "external",
cwd: "/test",
sessionId: "session-1",
version: "1.0.0",
parentUuid: null,
});
const result = parseJsonl(jsonl);
expect(result).toHaveLength(1);
const entry = result[0];
if (entry && entry.type === "user") {
expect(entry.parentUuid).toBeNull();
}
});
});
});

View File

@@ -5,9 +5,9 @@ import type { Conversation } from "../../../lib/conversation-schema";
import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService"; import { PersistentService } from "../../lib/storage/FileCacheStorage/PersistantService";
import { decodeProjectId } from "../project/id"; import { decodeProjectId } from "../project/id";
import type { ErrorJsonl, SessionDetail, SessionMeta } from "../types"; import type { ErrorJsonl, SessionDetail, SessionMeta } from "../types";
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
import { SessionMetaService } from "./SessionMetaService"; import { SessionMetaService } from "./SessionMetaService";
import { SessionRepository } from "./SessionRepository"; import { SessionRepository } from "./SessionRepository";
import { VirtualConversationDatabase } from "./VirtualConversationDatabase";
/** /**
* Helper function to create a FileSystem mock layer * Helper function to create a FileSystem mock layer

View File

@@ -1,14 +1,13 @@
import { resolve } from "node:path"; import { resolve } from "node:path";
import { FileSystem } from "@effect/platform"; import { FileSystem } from "@effect/platform";
import { Context, Effect, Layer, Option } from "effect"; import { Context, Effect, Layer, Option } from "effect";
import { uniqBy } from "es-toolkit";
import { parseCommandXml } from "../parseCommandXml"; import { parseCommandXml } from "../parseCommandXml";
import { parseJsonl } from "../parseJsonl"; import { parseJsonl } from "../parseJsonl";
import { decodeProjectId } from "../project/id"; import { decodeProjectId } from "../project/id";
import type { Session, SessionDetail } from "../types"; import type { Session, SessionDetail } from "../types";
import { decodeSessionId, encodeSessionId } from "./id"; import { decodeSessionId, encodeSessionId } from "./id";
import { VirtualConversationDatabase } from "./PredictSessionsDatabase";
import { SessionMetaService } from "./SessionMetaService"; import { SessionMetaService } from "./SessionMetaService";
import { VirtualConversationDatabase } from "./VirtualConversationDatabase";
const getSession = (projectId: string, sessionId: string) => const getSession = (projectId: string, sessionId: string) =>
Effect.gen(function* () { Effect.gen(function* () {
@@ -75,25 +74,7 @@ const getSession = (projectId: string, sessionId: string) =>
id: sessionId, id: sessionId,
jsonlFilePath: sessionPath, jsonlFilePath: sessionPath,
meta, meta,
conversations: isBroken conversations: isBroken ? conversations : mergedConversations,
? conversations
: uniqBy(mergedConversations, (item) => {
switch (item.type) {
case "system":
return `${item.type}-${item.uuid}`;
case "assistant":
return `${item.type}-${item.message.id}`;
case "user":
return `${item.type}-${item.message.content}`;
case "summary":
return `${item.type}-${item.leafUuid}`;
case "x-error":
return `${item.type}-${item.lineNumber}-${item.line}`;
default:
item satisfies never;
throw new Error(`Unknown conversation type: ${item}`);
}
}),
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
}; };

View File

@@ -1,7 +1,7 @@
import { Effect } from "effect"; import { Effect } from "effect";
import type { Conversation } from "../../../lib/conversation-schema"; import type { Conversation } from "../../../lib/conversation-schema";
import type { ErrorJsonl } from "../types"; import type { ErrorJsonl } from "../types";
import { VirtualConversationDatabase } from "./PredictSessionsDatabase"; import { VirtualConversationDatabase } from "./VirtualConversationDatabase";
describe("VirtualConversationDatabase", () => { describe("VirtualConversationDatabase", () => {
describe("getProjectVirtualConversations", () => { describe("getProjectVirtualConversations", () => {