fix: bug fix related to effect-ts refactor

This commit is contained in:
d-kimsuon
2025-10-17 17:16:08 +09:00
parent 1795cb499b
commit a5d81568bb
28 changed files with 1022 additions and 196 deletions

View File

@@ -50,6 +50,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.85.5", "@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

36
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@radix-ui/react-tabs': '@radix-ui/react-tabs':
specifier: ^1.1.13 specifier: ^1.1.13
version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1) version: 1.1.13(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.85.5 specifier: ^5.85.5
version: 5.85.5(react@19.1.1) version: 5.85.5(react@19.1.1)
@@ -1319,6 +1322,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1': '@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==} resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies: peerDependencies:
@@ -4570,6 +4586,26 @@ snapshots:
'@types/react': 19.1.12 '@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12) '@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-context': 1.1.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-dismissable-layer': 1.1.11(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-id': 1.1.1(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-popper': 1.2.8(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-portal': 1.1.9(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-presence': 1.1.5(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
'@radix-ui/react-slot': 1.2.3(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.1.12)(react@19.1.1)
'@radix-ui/react-visually-hidden': 1.2.3(@types/react-dom@19.1.9(@types/react@19.1.12))(@types/react@19.1.12)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
optionalDependencies:
'@types/react': 19.1.12
'@types/react-dom': 19.1.9(@types/react@19.1.12)
'@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)': '@radix-ui/react-use-callback-ref@1.1.1(@types/react@19.1.12)(react@19.1.1)':
dependencies: dependencies:
react: 19.1.1 react: 19.1.1

View File

@@ -175,6 +175,8 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
return ( return (
<a <a
href={href} href={href}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 underline underline-offset-4 decoration-primary/30 hover:decoration-primary/60 transition-colors" className="text-primary hover:text-primary/80 underline underline-offset-4 decoration-primary/30 hover:decoration-primary/60 transition-colors"
{...props} {...props}
> >

View File

@@ -21,7 +21,5 @@ export default async function LatestSessionPage({
redirect(`/projects`); redirect(`/projects`);
} }
redirect( redirect(`/projects/${projectId}/sessions/${latestSession.id}`);
`/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(latestSession.id)}`,
);
} }

View File

@@ -6,5 +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`); redirect(`/projects/${projectId}/latest`);
} }

View File

@@ -1,11 +1,24 @@
"use client"; "use client";
import { MessageSquareIcon, PlugIcon, SettingsIcon, XIcon } from "lucide-react"; import {
ArrowLeftIcon,
MessageSquareIcon,
PlugIcon,
SettingsIcon,
XIcon,
} from "lucide-react";
import Link from "next/link";
import { type FC, Suspense, 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 { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls"; import { SettingsControls } from "@/components/SettingsControls";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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";
@@ -157,52 +170,89 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
> >
{/* Tab Icons */} {/* Tab Icons */}
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50"> <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"> <TooltipProvider>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={() => handleTabClick("sessions")} <Link
className={cn( href="/projects"
"w-8 h-8 flex items-center justify-center rounded-md transition-colors", className="w-12 h-12 flex items-center justify-center border-b border-sidebar-border hover:bg-sidebar-accent transition-colors"
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", >
activeTab === "sessions" <ArrowLeftIcon className="w-4 h-4 text-sidebar-foreground/70" />
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm" </Link>
: "text-sidebar-foreground/70", </TooltipTrigger>
)} <TooltipContent side="right">
data-testid="sessions-tab-button-mobile" <p></p>
> </TooltipContent>
<MessageSquareIcon className="w-4 h-4" /> </Tooltip>
</button>
<button <div className="flex flex-col p-2 space-y-1">
type="button" <Tooltip>
onClick={() => handleTabClick("mcp")} <TooltipTrigger asChild>
className={cn( <button
"w-8 h-8 flex items-center justify-center rounded-md transition-colors", type="button"
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", onClick={() => handleTabClick("sessions")}
activeTab === "mcp" className={cn(
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm" "w-8 h-8 flex items-center justify-center rounded-md transition-colors",
: "text-sidebar-foreground/70", "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
)} activeTab === "sessions"
data-testid="mcp-tab-button-mobile" ? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
> : "text-sidebar-foreground/70",
<PlugIcon className="w-4 h-4" /> )}
</button> data-testid="sessions-tab-button-mobile"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
</TooltipContent>
</Tooltip>
<button <Tooltip>
type="button" <TooltipTrigger asChild>
onClick={() => handleTabClick("settings")} <button
className={cn( type="button"
"w-8 h-8 flex items-center justify-center rounded-md transition-colors", onClick={() => handleTabClick("mcp")}
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", className={cn(
activeTab === "settings" "w-8 h-8 flex items-center justify-center rounded-md transition-colors",
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm" "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
: "text-sidebar-foreground/70", activeTab === "mcp"
)} ? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
data-testid="settings-tab-button-mobile" : "text-sidebar-foreground/70",
> )}
<SettingsIcon className="w-4 h-4" /> data-testid="mcp-tab-button-mobile"
</button> >
</div> <PlugIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>MCPサーバー設定を表示</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<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"
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="settings-tab-button-mobile"
>
<SettingsIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
</div> </div>
{/* Content Area */} {/* Content Area */}

View File

@@ -1,9 +1,16 @@
"use client"; "use client";
import { MessageSquareIcon, PlugIcon } from "lucide-react"; import { ArrowLeftIcon, MessageSquareIcon, PlugIcon } from "lucide-react";
import Link from "next/link";
import { type FC, useMemo } from "react"; import { type FC, useMemo } from "react";
import type { SidebarTab } from "@/components/GlobalSidebar"; import type { SidebarTab } from "@/components/GlobalSidebar";
import { GlobalSidebar } from "@/components/GlobalSidebar"; import { GlobalSidebar } from "@/components/GlobalSidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
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";
@@ -36,7 +43,7 @@ export const SessionSidebar: FC<{
{ {
id: "sessions", id: "sessions",
icon: MessageSquareIcon, icon: MessageSquareIcon,
title: "Sessions", title: "セッション一覧を表示",
content: ( content: (
<SessionsTab <SessionsTab
sessions={sessions.map((session) => ({ sessions={sessions.map((session) => ({
@@ -54,7 +61,7 @@ export const SessionSidebar: FC<{
{ {
id: "mcp", id: "mcp",
icon: PlugIcon, icon: PlugIcon,
title: "MCP Servers", title: "MCPサーバー設定を表示",
content: <McpTab projectId={projectId} />, content: <McpTab projectId={projectId} />,
}, },
], ],
@@ -76,6 +83,23 @@ export const SessionSidebar: FC<{
projectId={projectId} projectId={projectId}
additionalTabs={additionalTabs} additionalTabs={additionalTabs}
defaultActiveTab="sessions" defaultActiveTab="sessions"
headerButton={
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/projects"
className="w-12 h-12 flex items-center justify-center hover:bg-sidebar-accent transition-colors"
>
<ArrowLeftIcon className="w-4 h-4 text-sidebar-foreground/70" />
</Link>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
}
/> />
</div> </div>

View File

@@ -99,9 +99,7 @@ export const SessionsTab: FC<{
return ( return (
<Link <Link
key={session.id} key={session.id}
href={`/projects/${projectId}/sessions/${encodeURIComponent( href={`/projects/${projectId}/sessions/${session.id}`}
session.id,
)}`}
className={cn( className={cn(
"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", "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 &&

View File

@@ -0,0 +1,105 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { Loader2, Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { type FC, useState } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { honoClient } from "@/lib/api/client";
import { DirectoryPicker } from "./DirectoryPicker";
export const CreateProjectDialog: FC = () => {
const [open, setOpen] = useState(false);
const [selectedPath, setSelectedPath] = useState<string>("");
const router = useRouter();
const createProjectMutation = useMutation({
mutationFn: async () => {
const response = await honoClient.api.projects.$post({
json: { projectPath: selectedPath },
});
if (!response.ok) {
throw new Error("Failed to create project");
}
return await response.json();
},
onSuccess: (result) => {
toast.success("Project created successfully");
setOpen(false);
router.push(`/projects/${result.projectId}/sessions/${result.sessionId}`);
},
onError: (error) => {
toast.error(
error instanceof Error ? error.message : "Failed to create project",
);
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Plus className="w-4 h-4 mr-2" />
New Project
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Create New Project</DialogTitle>
<DialogDescription>
Select a directory to initialize as a Claude Code project. This will
run{" "}
<code className="text-sm bg-muted px-1 py-0.5 rounded">/init</code>{" "}
in the selected directory.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<DirectoryPicker
selectedPath={selectedPath}
onPathChange={setSelectedPath}
/>
{selectedPath ? (
<div className="mt-4 p-3 bg-muted rounded-md">
<p className="text-sm font-medium mb-1">Selected directory:</p>
<p className="text-sm text-muted-foreground font-mono">
{selectedPath}
</p>
</div>
) : null}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button
onClick={async () => await createProjectMutation.mutateAsync()}
disabled={!selectedPath || createProjectMutation.isPending}
>
{createProjectMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
"Create Project"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,76 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Folder } from "lucide-react";
import { type FC, useState } from "react";
import { Button } from "@/components/ui/button";
import { directoryListingQuery } from "@/lib/api/queries";
export type DirectoryPickerProps = {
selectedPath: string;
onPathChange: (path: string) => void;
};
export const DirectoryPicker: FC<DirectoryPickerProps> = ({ onPathChange }) => {
const [currentPath, setCurrentPath] = useState<string | undefined>(undefined);
const { data, isLoading } = useQuery(directoryListingQuery(currentPath));
const handleNavigate = (entryPath: string) => {
if (entryPath === "") {
setCurrentPath(undefined);
return;
}
const newPath = `/${entryPath}`;
setCurrentPath(newPath);
};
const handleSelect = () => {
onPathChange(data?.currentPath || "");
};
return (
<div className="border rounded-md">
<div className="p-3 border-b bg-muted/50 flex items-center justify-between">
<p className="text-sm font-medium">
Current: <span className="font-mono">{data?.currentPath || "~"}</span>
</p>
<Button size="sm" onClick={handleSelect}>
Select This Directory
</Button>
</div>
<div className="max-h-96 overflow-auto">
{isLoading ? (
<div className="p-8 text-center text-sm text-muted-foreground">
Loading...
</div>
) : data?.entries && data.entries.length > 0 ? (
<div className="divide-y">
{data.entries
.filter((entry) => entry.type === "directory")
.map((entry) => (
<button
key={entry.path}
type="button"
onClick={() => handleNavigate(entry.path)}
className="w-full px-3 py-2 flex items-center gap-2 hover:bg-muted/50 transition-colors text-left"
>
{entry.name === ".." ? (
<ChevronRight className="w-4 h-4 rotate-180" />
) : (
<Folder className="w-4 h-4 text-blue-500" />
)}
<span className="text-sm">{entry.name}</span>
</button>
))}
</div>
) : (
<div className="p-8 text-center text-sm text-muted-foreground">
No directories found
</div>
)}
</div>
</div>
);
};

View File

@@ -59,7 +59,7 @@ 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)}/latest`}> <Link href={`/projects/${project.id}/latest`}>
View Conversations View Conversations
</Link> </Link>
</Button> </Button>

View File

@@ -3,6 +3,7 @@
import { HistoryIcon } from "lucide-react"; import { HistoryIcon } from "lucide-react";
import { Suspense } from "react"; import { Suspense } from "react";
import { GlobalSidebar } from "@/components/GlobalSidebar"; import { GlobalSidebar } from "@/components/GlobalSidebar";
import { CreateProjectDialog } from "./components/CreateProjectDialog";
import { ProjectList } from "./components/ProjectList"; import { ProjectList } from "./components/ProjectList";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -27,7 +28,10 @@ export default function ProjectsPage() {
<main> <main>
<section> <section>
<h2 className="text-xl font-semibold mb-4">Your Projects</h2> <div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold">Your Projects</h2>
<CreateProjectDialog />
</div>
<Suspense <Suspense
fallback={ fallback={
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">

View File

@@ -3,6 +3,12 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { SettingsIcon } from "lucide-react"; import { SettingsIcon } from "lucide-react";
import { type FC, type ReactNode, Suspense, useState } from "react"; import { type FC, type ReactNode, Suspense, useState } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NotificationSettings } from "./NotificationSettings"; import { NotificationSettings } from "./NotificationSettings";
import { SettingsControls } from "./SettingsControls"; import { SettingsControls } from "./SettingsControls";
@@ -19,6 +25,7 @@ interface GlobalSidebarProps {
className?: string; className?: string;
additionalTabs?: SidebarTab[]; additionalTabs?: SidebarTab[];
defaultActiveTab?: string; defaultActiveTab?: string;
headerButton?: ReactNode;
} }
export const GlobalSidebar: FC<GlobalSidebarProps> = ({ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
@@ -26,11 +33,12 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
className, className,
additionalTabs = [], additionalTabs = [],
defaultActiveTab, defaultActiveTab,
headerButton,
}) => { }) => {
const settingsTab: SidebarTab = { const settingsTab: SidebarTab = {
id: "settings", id: "settings",
icon: SettingsIcon, icon: SettingsIcon,
title: "Settings", title: "表示と通知の設定",
content: ( content: (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4"> <div className="border-b border-sidebar-border p-4">
@@ -96,29 +104,39 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
> >
{/* Vertical Icon Menu - Always Visible */} {/* Vertical Icon Menu - Always Visible */}
<div className="w-12 flex flex-col border-r border-sidebar-border bg-sidebar/50"> <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"> <TooltipProvider>
{allTabs.map((tab) => { {headerButton && (
const Icon = tab.icon; <div className="border-b border-sidebar-border">{headerButton}</div>
return ( )}
<button <div className="flex flex-col p-2 space-y-1">
key={tab.id} {allTabs.map((tab) => {
type="button" const Icon = tab.icon;
onClick={() => handleTabClick(tab.id)} return (
className={cn( <Tooltip key={tab.id}>
"w-8 h-8 flex items-center justify-center rounded-md transition-colors", <TooltipTrigger asChild>
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", <button
activeTab === tab.id && isExpanded type="button"
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm" onClick={() => handleTabClick(tab.id)}
: "text-sidebar-foreground/70", className={cn(
)} "w-8 h-8 flex items-center justify-center rounded-md transition-colors",
title={tab.title} "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
data-testid={`${tab.id}-tab-button`} activeTab === tab.id && isExpanded
> ? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
<Icon className="w-4 h-4" /> : "text-sidebar-foreground/70",
</button> )}
); data-testid={`${tab.id}-tab-button`}
})} >
</div> <Icon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{tab.title}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
</div> </div>
{/* Content Area - Only shown when expanded */} {/* Content Area - Only shown when expanded */}

View File

@@ -0,0 +1,61 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";
import { cn } from "@/lib/utils";
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

View File

@@ -15,7 +15,7 @@ export const usePermissionRequests = () => {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
// Listen for permission requests from the server // Listen for permission requests from the server
useServerEventListener("permission_requested", (data) => { useServerEventListener("permissionRequested", (data) => {
if (data.permissionRequest) { if (data.permissionRequest) {
setCurrentPermissionRequest(data.permissionRequest); setCurrentPermissionRequest(data.permissionRequest);
setIsDialogOpen(true); setIsDialogOpen(true);

View File

@@ -23,7 +23,6 @@ export const makeQueryClient = () =>
defaultOptions: { defaultOptions: {
queries: { queries: {
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
refetchInterval: 1000 * 60 * 5,
retry: false, retry: false,
}, },
}, },

View File

@@ -1,3 +1,4 @@
import type { DirectoryListingResult } from "../../server/service/directory-browser/getDirectoryListing";
import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion"; import type { FileCompletionResult } from "../../server/service/file-completion/getFileCompletion";
import { honoClient } from "./client"; import { honoClient } from "./client";
@@ -16,6 +17,22 @@ export const projectListQuery = {
}, },
} as const; } as const;
export const directoryListingQuery = (currentPath?: string) =>
({
queryKey: ["directory-listing", currentPath],
queryFn: async (): Promise<DirectoryListingResult> => {
const response = await honoClient.api["directory-browser"].$get({
query: currentPath ? { currentPath } : {},
});
if (!response.ok) {
throw new Error("Failed to fetch directory listing");
}
return await response.json();
},
}) as const;
export const projectDetailQuery = (projectId: string, cursor?: string) => export const projectDetailQuery = (projectId: string, cursor?: string) =>
({ ({
queryKey: ["projects", projectId], queryKey: ["projects", projectId],
@@ -70,7 +87,7 @@ export const sessionDetailQuery = (projectId: string, sessionId: string) =>
throw new Error(`Failed to fetch session: ${response.statusText}`); throw new Error(`Failed to fetch session: ${response.statusText}`);
} }
return response.json(); return await response.json();
}, },
}) as const; }) as const;

View File

@@ -1,4 +1,5 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path"; import { resolve } from "node:path";
import type { CommandExecutor, FileSystem, Path } from "@effect/platform"; import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
import { zValidator } from "@hono/zod-validator"; import { zValidator } from "@hono/zod-validator";
@@ -12,6 +13,8 @@ import { configSchema } from "../config/config";
import { env } from "../lib/env"; import { env } from "../lib/env";
import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService"; import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService"; import { ClaudeCodePermissionService } from "../service/claude-code/ClaudeCodePermissionService";
import { computeClaudeProjectFilePath } from "../service/claude-code/computeClaudeProjectFilePath";
import { getDirectoryListing } from "../service/directory-browser/getDirectoryListing";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { EventBus } from "../service/events/EventBus"; import { EventBus } from "../service/events/EventBus";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
@@ -22,6 +25,7 @@ import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff"; import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList"; import { getMcpList } from "../service/mcp/getMcpList";
import { claudeCommandsDirPath } from "../service/paths"; import { claudeCommandsDirPath } from "../service/paths";
import { encodeProjectId } from "../service/project/id";
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 { SessionMetaService } from "../service/session/SessionMetaService"; import type { SessionMetaService } from "../service/session/SessionMetaService";
@@ -92,6 +96,87 @@ export const routes = (app: HonoAppType) =>
return c.json({ projects }); return c.json({ projects });
}) })
.post(
"/projects",
zValidator(
"json",
z.object({
projectPath: z.string().min(1, "Project path is required"),
}),
),
async (c) => {
const { projectPath } = c.req.valid("json");
// No project validation needed - startTask will create a new project
// if it doesn't exist when running /init command
const claudeProjectFilePath =
computeClaudeProjectFilePath(projectPath);
const projectId = encodeProjectId(claudeProjectFilePath);
const program = Effect.gen(function* () {
const result = yield* claudeCodeLifeCycleService.startTask({
baseSession: {
cwd: projectPath,
projectId,
sessionId: undefined,
},
config: c.get("config"),
message: "/init",
});
return {
result,
status: 200 as const,
};
});
const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionFileCreated();
return c.json({
projectId: result.result.sessionProcess.def.projectId,
sessionId,
});
}
return c.json({ error: "Failed to create project" }, 500);
},
)
.get(
"/directory-browser",
zValidator(
"query",
z.object({
currentPath: z.string().optional(),
}),
),
async (c) => {
const { currentPath } = c.req.valid("query");
const rootPath = "/";
const defaultPath = homedir();
try {
const targetPath = currentPath || defaultPath;
const relativePath = targetPath.startsWith(rootPath)
? targetPath.slice(rootPath.length)
: targetPath;
const result = await getDirectoryListing(rootPath, relativePath);
return c.json(result);
} catch (error) {
console.error("Directory listing error:", error);
if (error instanceof Error) {
return c.json({ error: error.message }, 400);
}
return c.json({ error: "Failed to list directory" }, 500);
}
},
)
.get( .get(
"/projects/:projectId", "/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })), zValidator("query", z.object({ cursor: z.string().optional() })),
@@ -172,10 +257,11 @@ export const routes = (app: HonoAppType) =>
filteredSessions = Array.from(sessionMap.values()); filteredSessions = Array.from(sessionMap.values());
} }
const hasMore = sessions.length >= 20;
return { return {
project, project,
sessions: filteredSessions, sessions: filteredSessions,
nextCursor: sessions.at(-1)?.id, nextCursor: hasMore ? sessions.at(-1)?.id : undefined,
}; };
}); });
@@ -498,11 +584,14 @@ export const routes = (app: HonoAppType) =>
const result = await Runtime.runPromise(runtime)(program); const result = await Runtime.runPromise(runtime)(program);
if (result.status === 200) { if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionInitialized();
return c.json({ return c.json({
sessionProcess: { sessionProcess: {
id: result.result.sessionProcess.def.sessionProcessId, id: result.result.sessionProcess.def.sessionProcessId,
projectId: result.result.sessionProcess.def.projectId, projectId: result.result.sessionProcess.def.projectId,
sessionId: await result.result.awaitSessionInitialized(), sessionId,
}, },
}); });
} }
@@ -648,6 +737,16 @@ export const routes = (app: HonoAppType) =>
); );
}; };
const onPermissionRequested = (
event: InternalEventDeclaration["permissionRequested"],
) => {
Effect.runFork(
typeSafeSSE.writeSSE("permissionRequested", {
permissionRequest: event.permissionRequest,
}),
);
};
yield* eventBus.on("sessionListChanged", onSessionListChanged); yield* eventBus.on("sessionListChanged", onSessionListChanged);
yield* eventBus.on("sessionChanged", onSessionChanged); yield* eventBus.on("sessionChanged", onSessionChanged);
yield* eventBus.on( yield* eventBus.on(
@@ -655,6 +754,10 @@ export const routes = (app: HonoAppType) =>
onSessionProcessChanged, onSessionProcessChanged,
); );
yield* eventBus.on("heartbeat", onHeartbeat); yield* eventBus.on("heartbeat", onHeartbeat);
yield* eventBus.on(
"permissionRequested",
onPermissionRequested,
);
const { connectionPromise } = adaptInternalEventToSSE( const { connectionPromise } = adaptInternalEventToSSE(
rawStream, rawStream,
@@ -676,6 +779,10 @@ export const routes = (app: HonoAppType) =>
onSessionProcessChanged, onSessionProcessChanged,
); );
yield* eventBus.off("heartbeat", onHeartbeat); yield* eventBus.off("heartbeat", onHeartbeat);
yield* eventBus.off(
"permissionRequested",
onPermissionRequested,
);
}), }),
); );
}, },

View File

@@ -118,7 +118,12 @@ const LayerImpl = Effect.gen(function* () {
}, },
}); });
const sessionInitializedPromise = controllablePromise<string>(); const sessionInitializedPromise = controllablePromise<{
sessionId: string;
}>();
const sessionFileCreatedPromise = controllablePromise<{
sessionId: string;
}>();
setMessageGeneratorHooks({ setMessageGeneratorHooks({
onNewUserMessageResolved: async (message) => { onNewUserMessageResolved: async (message) => {
@@ -194,7 +199,9 @@ const LayerImpl = Effect.gen(function* () {
// do nothing // do nothing
} }
sessionInitializedPromise.resolve(message.session_id); sessionInitializedPromise.resolve({
sessionId: message.session_id,
});
yield* eventBusService.emit("sessionListChanged", { yield* eventBusService.emit("sessionListChanged", {
projectId: processState.def.projectId, projectId: processState.def.projectId,
@@ -216,6 +223,10 @@ const LayerImpl = Effect.gen(function* () {
sessionProcessId: processState.def.sessionProcessId, sessionProcessId: processState.def.sessionProcessId,
}); });
sessionFileCreatedPromise.resolve({
sessionId: message.session_id,
});
yield* virtualConversationDatabase.deleteVirtualConversations( yield* virtualConversationDatabase.deleteVirtualConversations(
message.session_id, message.session_id,
); );
@@ -329,6 +340,8 @@ const LayerImpl = Effect.gen(function* () {
daemonPromise, daemonPromise,
awaitSessionInitialized: async () => awaitSessionInitialized: async () =>
await sessionInitializedPromise.promise, await sessionInitializedPromise.promise,
awaitSessionFileCreated: async () =>
await sessionFileCreatedPromise.promise,
}; };
}); });
}; };

View File

@@ -1,4 +1,7 @@
import type { SDKResultMessage, SDKSystemMessage } from "@anthropic-ai/claude-code"; import type {
SDKResultMessage,
SDKSystemMessage,
} from "@anthropic-ai/claude-code";
import { Effect, Layer } from "effect"; import { Effect, Layer } from "effect";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { EventBus } from "../events/EventBus"; import { EventBus } from "../events/EventBus";
@@ -38,7 +41,6 @@ const createMockContinueTaskDef = (
baseSessionId, baseSessionId,
}); });
// Helper function to create mock init context // Helper function to create mock init context
const createMockInitContext = (sessionId: string): InitMessageContext => ({ const createMockInitContext = (sessionId: string): InitMessageContext => ({
initMessage: { initMessage: {
@@ -48,11 +50,12 @@ const createMockInitContext = (sessionId: string): InitMessageContext => ({
}); });
// Helper function to create mock result message // Helper function to create mock result message
const createMockResultMessage = (sessionId: string): SDKResultMessage => ({ const createMockResultMessage = (sessionId: string): SDKResultMessage =>
type: "result", ({
session_id: sessionId, type: "result",
result: {}, session_id: sessionId,
} as SDKResultMessage); result: {},
}) as SDKResultMessage;
// Mock EventBus for testing // Mock EventBus for testing
const MockEventBus = Layer.succeed(EventBus, { const MockEventBus = Layer.succeed(EventBus, {
@@ -581,7 +584,9 @@ describe("ClaudeCodeSessionProcessService", () => {
program.pipe(Effect.provide(TestLayer)), program.pipe(Effect.provide(TestLayer)),
); );
const completedTask = process.tasks.find((t) => t.def.taskId === "task-1"); const completedTask = process.tasks.find(
(t) => t.def.taskId === "task-1",
);
expect(completedTask?.status).toBe("completed"); expect(completedTask?.status).toBe("completed");
if (completedTask?.status === "completed") { if (completedTask?.status === "completed") {
expect(completedTask.sessionId).toBe("session-1"); expect(completedTask.sessionId).toBe("session-1");
@@ -758,9 +763,7 @@ describe("ClaudeCodeSessionProcessService", () => {
const program = Effect.gen(function* () { const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService; const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip( const result = yield* Effect.flip(service.getTask("non-existent-task"));
service.getTask("non-existent-task"),
);
return result; return result;
}); });
@@ -857,7 +860,11 @@ describe("ClaudeCodeSessionProcessService", () => {
}); });
// Continue with second task // Continue with second task
const taskDef2 = createMockContinueTaskDef("task-2", "session-1", "session-1"); const taskDef2 = createMockContinueTaskDef(
"task-2",
"session-1",
"session-1",
);
const continueResult = yield* service.continueSessionProcess({ const continueResult = yield* service.continueSessionProcess({
sessionProcessId: "process-1", sessionProcessId: "process-1",

View File

@@ -0,0 +1,47 @@
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("computeClaudeProjectFilePath", () => {
const TEST_GLOBAL_CLAUDE_DIR = "/test/mock/claude";
const TEST_PROJECTS_DIR = path.join(TEST_GLOBAL_CLAUDE_DIR, "projects");
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../lib/env", () => ({
env: {
get: (key: string) => {
if (key === "GLOBAL_CLAUDE_DIR") {
return TEST_GLOBAL_CLAUDE_DIR;
}
return undefined;
},
},
}));
});
it("プロジェクトパスからClaudeの設定ディレクトリパスを計算する", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
);
const projectPath = "/home/me/dev/example";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
expect(result).toBe(expected);
});
it("末尾にスラッシュがある場合も正しく処理される", async () => {
const { computeClaudeProjectFilePath } = await import(
"./computeClaudeProjectFilePath"
);
const projectPath = "/home/me/dev/example/";
const expected = `${TEST_PROJECTS_DIR}/-home-me-dev-example`;
const result = computeClaudeProjectFilePath(projectPath);
expect(result).toBe(expected);
});
});

View File

@@ -0,0 +1,9 @@
import path from "node:path";
import { claudeProjectsDirPath } from "../paths";
export function computeClaudeProjectFilePath(projectPath: string): string {
return path.join(
claudeProjectsDirPath,
projectPath.replace(/\/$/, "").replace(/\//g, "-"),
);
}

View File

@@ -0,0 +1,151 @@
import { mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { getDirectoryListing } from "./getDirectoryListing";
describe("getDirectoryListing", () => {
let testDir: string;
beforeEach(async () => {
testDir = join(tmpdir(), `test-dir-${Date.now()}`);
await mkdir(testDir, { recursive: true });
});
afterEach(async () => {
await rm(testDir, { recursive: true, force: true });
});
test("should list directories and files", async () => {
await mkdir(join(testDir, "subdir1"));
await mkdir(join(testDir, "subdir2"));
await writeFile(join(testDir, "file1.txt"), "content1");
await writeFile(join(testDir, "file2.txt"), "content2");
const result = await getDirectoryListing(testDir);
expect(result.entries).toHaveLength(4);
expect(result.entries).toEqual([
{ name: "subdir1", type: "directory", path: "subdir1" },
{ name: "subdir2", type: "directory", path: "subdir2" },
{ name: "file1.txt", type: "file", path: "file1.txt" },
{ name: "file2.txt", type: "file", path: "file2.txt" },
]);
expect(result.basePath).toBe("/");
expect(result.currentPath).toBe(testDir);
});
test("should navigate to subdirectory", async () => {
await mkdir(join(testDir, "parent"));
await mkdir(join(testDir, "parent", "child"));
await writeFile(join(testDir, "parent", "file.txt"), "content");
const result = await getDirectoryListing(testDir, "parent");
expect(result.entries).toHaveLength(3);
expect(result.entries).toEqual([
{ name: "..", type: "directory", path: "" },
{ name: "child", type: "directory", path: "parent/child" },
{ name: "file.txt", type: "file", path: "parent/file.txt" },
]);
expect(result.basePath).toBe("parent");
});
test("should skip hidden files and directories", async () => {
await mkdir(join(testDir, ".hidden-dir"));
await writeFile(join(testDir, ".hidden-file"), "content");
await mkdir(join(testDir, "visible-dir"));
await writeFile(join(testDir, "visible-file.txt"), "content");
const result = await getDirectoryListing(testDir);
expect(result.entries).toHaveLength(2);
expect(result.entries.some((e) => e.name.startsWith("."))).toBe(false);
});
test("should sort directories before files alphabetically", async () => {
await mkdir(join(testDir, "z-dir"));
await mkdir(join(testDir, "a-dir"));
await writeFile(join(testDir, "z-file.txt"), "content");
await writeFile(join(testDir, "a-file.txt"), "content");
const result = await getDirectoryListing(testDir);
expect(result.entries).toEqual([
{ name: "a-dir", type: "directory", path: "a-dir" },
{ name: "z-dir", type: "directory", path: "z-dir" },
{ name: "a-file.txt", type: "file", path: "a-file.txt" },
{ name: "z-file.txt", type: "file", path: "z-file.txt" },
]);
});
test("should return empty entries for non-existent directory", async () => {
const result = await getDirectoryListing(join(testDir, "non-existent"));
expect(result.entries).toEqual([]);
expect(result.basePath).toBe("/");
});
test("should prevent directory traversal", async () => {
await expect(getDirectoryListing(testDir, "../../../etc")).rejects.toThrow(
"Invalid path: outside root directory",
);
});
test("should handle basePath with leading slash", async () => {
await mkdir(join(testDir, "subdir"));
await writeFile(join(testDir, "subdir", "file.txt"), "content");
const result = await getDirectoryListing(testDir, "/subdir");
expect(result.entries).toHaveLength(2);
expect(result.entries).toEqual([
{ name: "..", type: "directory", path: "" },
{ name: "file.txt", type: "file", path: "subdir/file.txt" },
]);
expect(result.basePath).toBe("subdir");
});
test("should include parent directory entry when not at root", async () => {
await mkdir(join(testDir, "parent"));
await mkdir(join(testDir, "parent", "child"));
const result = await getDirectoryListing(testDir, "parent");
const parentEntry = result.entries.find((e) => e.name === "..");
expect(parentEntry).toEqual({
name: "..",
type: "directory",
path: "",
});
});
test("should not include parent directory entry at root", async () => {
await mkdir(join(testDir, "subdir"));
const result = await getDirectoryListing(testDir);
const parentEntry = result.entries.find((e) => e.name === "..");
expect(parentEntry).toBeUndefined();
});
test("should use absolute paths in currentPath for navigation", async () => {
await mkdir(join(testDir, "level1"));
await mkdir(join(testDir, "level1", "level2"));
const rootResult = await getDirectoryListing(testDir);
expect(rootResult.currentPath).toBe(testDir);
const level1Entry = rootResult.entries.find((e) => e.name === "level1");
expect(level1Entry).toBeDefined();
const level1Result = await getDirectoryListing(testDir, level1Entry?.path);
expect(level1Result.currentPath).toBe(join(testDir, "level1"));
const level2Entry = level1Result.entries.find((e) => e.name === "level2");
expect(level2Entry).toBeDefined();
const level2Result = await getDirectoryListing(testDir, level2Entry?.path);
expect(level2Result.currentPath).toBe(join(testDir, "level1", "level2"));
});
});

View File

@@ -0,0 +1,100 @@
import { existsSync } from "node:fs";
import { readdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
export type DirectoryEntry = {
name: string;
type: "file" | "directory";
path: string;
};
export type DirectoryListingResult = {
entries: DirectoryEntry[];
basePath: string;
currentPath: string;
};
export const getDirectoryListing = async (
rootPath: string,
basePath = "/",
): Promise<DirectoryListingResult> => {
const normalizedBasePath =
basePath === "/"
? ""
: basePath.startsWith("/")
? basePath.slice(1)
: basePath;
const targetPath = resolve(rootPath, normalizedBasePath);
if (!targetPath.startsWith(resolve(rootPath))) {
throw new Error("Invalid path: outside root directory");
}
if (!existsSync(targetPath)) {
return {
entries: [],
basePath: "/",
currentPath: rootPath,
};
}
try {
const dirents = await readdir(targetPath, { withFileTypes: true });
const entries: DirectoryEntry[] = [];
if (normalizedBasePath !== "") {
const parentPath = dirname(normalizedBasePath);
entries.push({
name: "..",
type: "directory",
path: parentPath === "." ? "" : parentPath,
});
}
for (const dirent of dirents) {
if (dirent.name.startsWith(".")) {
continue;
}
const entryPath = normalizedBasePath
? join(normalizedBasePath, dirent.name)
: dirent.name;
if (dirent.isDirectory()) {
entries.push({
name: dirent.name,
type: "directory",
path: entryPath,
});
} else if (dirent.isFile()) {
entries.push({
name: dirent.name,
type: "file",
path: entryPath,
});
}
}
entries.sort((a, b) => {
if (a.name === "..") return -1;
if (b.name === "..") return 1;
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1;
}
return a.name.localeCompare(b.name);
});
return {
entries,
basePath: normalizedBasePath || "/",
currentPath: targetPath,
};
} catch (error) {
console.error("Error reading directory:", error);
return {
entries: [],
basePath: normalizedBasePath || "/",
currentPath: targetPath,
};
}
};

View File

@@ -1,7 +1,9 @@
import { type FSWatcher, watch } from "node:fs"; import { type FSWatcher, watch } from "node:fs";
import { join } from "node:path";
import { Context, Effect, Layer, Ref } from "effect"; import { Context, Effect, Layer, Ref } from "effect";
import z from "zod"; import z from "zod";
import { claudeProjectsDirPath } from "../paths"; import { claudeProjectsDirPath } from "../paths";
import { encodeProjectIdFromSessionFilePath } from "../project/id";
import { EventBus } from "./EventBus"; import { EventBus } from "./EventBus";
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/; const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
@@ -54,8 +56,13 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
if (!groups.success) return; if (!groups.success) return;
const { projectId, sessionId } = groups.data; const { sessionId } = groups.data;
const debounceKey = `${projectId}/${sessionId}`;
// フルパスを構築してエンコードされた projectId を取得
const fullPath = join(claudeProjectsDirPath, filename);
const encodedProjectId =
encodeProjectIdFromSessionFilePath(fullPath);
const debounceKey = `${encodedProjectId}/${sessionId}`;
Effect.runPromise( Effect.runPromise(
Effect.gen(function* () { Effect.gen(function* () {
@@ -68,14 +75,14 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
const newTimer = setTimeout(() => { const newTimer = setTimeout(() => {
Effect.runFork( Effect.runFork(
eventBus.emit("sessionChanged", { eventBus.emit("sessionChanged", {
projectId, projectId: encodedProjectId,
sessionId, sessionId,
}), }),
); );
Effect.runFork( Effect.runFork(
eventBus.emit("sessionListChanged", { eventBus.emit("sessionListChanged", {
projectId, projectId: encodedProjectId,
}), }),
); );

View File

@@ -22,7 +22,6 @@ describe("getCommits", () => {
def456|fix: bug fix|Jane Smith|2024-01-14 09:20: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`; ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({ vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true, success: true,
data: mockOutput, data: mockOutput,
@@ -70,7 +69,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
const mockCwd = "/test/repo"; const mockCwd = "/test/repo";
const mockOutput = ""; const mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({ vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true, success: true,
data: mockOutput, data: mockOutput,
@@ -92,7 +90,6 @@ def456|fix: bug fix|Jane Smith|2024-01-14 09:20:00 +0900
||missing data| ||missing data|
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`; ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({ vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true, success: true,
data: mockOutput, data: mockOutput,
@@ -156,7 +153,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
it("Gitコマンドが失敗した場合", async () => { it("Gitコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo"; const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({ vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false, success: false,
error: { error: {
@@ -206,7 +202,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
const mockCwd = "/test/my repo with spaces"; const mockCwd = "/test/my repo with spaces";
const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`; const mockOutput = `abc123|feat: test|Author|2024-01-15 10:30:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({ vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true, success: true,
data: mockOutput, data: mockOutput,
@@ -233,7 +228,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
def456|fix: bug|Author|2024-01-14 09:20:00 +0900 def456|fix: bug|Author|2024-01-14 09:20:00 +0900
`; `;
vi.mocked(utils.executeGitCommand).mockResolvedValue({ vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true, success: true,
data: mockOutput, data: mockOutput,

View File

@@ -1,109 +1,111 @@
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 type { InferEffect } from "../../lib/effect/types";
import { claudeProjectsDirPath } from "../paths"; import { claudeProjectsDirPath } from "../paths";
import type { Project } from "../types"; import type { Project } from "../types";
import { decodeProjectId, encodeProjectId } from "./id"; import { decodeProjectId, encodeProjectId } from "./id";
import { ProjectMetaService } from "./ProjectMetaService"; import { ProjectMetaService } from "./ProjectMetaService";
const getProject = (projectId: string) => const LayerImpl = Effect.gen(function* () {
Effect.gen(function* () { const fs = yield* FileSystem.FileSystem;
const fs = yield* FileSystem.FileSystem; const projectMetaService = yield* ProjectMetaService;
const projectMetaService = yield* ProjectMetaService;
const fullPath = decodeProjectId(projectId); const getProject = (projectId: string) =>
Effect.gen(function* () {
const fullPath = decodeProjectId(projectId);
// Check if project directory exists // Check if project directory exists
const exists = yield* fs.exists(fullPath); const exists = yield* fs.exists(fullPath);
if (!exists) { if (!exists) {
return yield* Effect.fail(new Error("Project not found")); return yield* Effect.fail(new Error("Project not found"));
} }
// Get file stats // Get file stats
const stat = yield* fs.stat(fullPath); const stat = yield* fs.stat(fullPath);
// Get project metadata // Get project metadata
const meta = yield* projectMetaService.getProjectMeta(projectId); const meta = yield* projectMetaService.getProjectMeta(projectId);
return { return {
project: { project: {
id: projectId, id: projectId,
claudeProjectPath: fullPath,
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
meta,
},
};
});
const getProjects = () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const projectMetaService = yield* ProjectMetaService;
// Check if the claude projects directory exists
const dirExists = yield* fs.exists(claudeProjectsDirPath);
if (!dirExists) {
console.warn(
`Claude projects directory not found at ${claudeProjectsDirPath}`,
);
return { projects: [] };
}
// Read directory entries
const entries = yield* fs.readDirectory(claudeProjectsDirPath);
// Filter directories and map to Project objects
const projectEffects = entries.map((entry) =>
Effect.gen(function* () {
const fullPath = resolve(claudeProjectsDirPath, entry);
// Check if it's a directory
const stat = yield* Effect.tryPromise(() =>
fs.stat(fullPath).pipe(Effect.runPromise),
).pipe(Effect.catchAll(() => Effect.succeed(null)));
if (!stat || stat.type !== "Directory") {
return null;
}
const id = encodeProjectId(fullPath);
const meta = yield* projectMetaService.getProjectMeta(id);
return {
id,
claudeProjectPath: fullPath, claudeProjectPath: fullPath,
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()), lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
meta, meta,
} satisfies Project; },
}), };
);
// Execute all effects in parallel and filter out nulls
const projectsWithNulls = yield* Effect.all(projectEffects, {
concurrency: "unbounded",
}); });
const projects = projectsWithNulls.filter((p): p is Project => p !== null);
// Sort by last modified date (newest first) const getProjects = () =>
const sortedProjects = projects.sort((a, b) => { Effect.gen(function* () {
return ( // Check if the claude projects directory exists
(b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) - const dirExists = yield* fs.exists(claudeProjectsDirPath);
(a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0) if (!dirExists) {
console.warn(
`Claude projects directory not found at ${claudeProjectsDirPath}`,
);
return { projects: [] };
}
// Read directory entries
const entries = yield* fs.readDirectory(claudeProjectsDirPath);
// Filter directories and map to Project objects
const projectEffects = entries.map((entry) =>
Effect.gen(function* () {
const fullPath = resolve(claudeProjectsDirPath, entry);
// Check if it's a directory
const stat = yield* Effect.tryPromise(() =>
fs.stat(fullPath).pipe(Effect.runPromise),
).pipe(Effect.catchAll(() => Effect.succeed(null)));
if (!stat || stat.type !== "Directory") {
return null;
}
const id = encodeProjectId(fullPath);
const meta = yield* projectMetaService.getProjectMeta(id);
return {
id,
claudeProjectPath: fullPath,
lastModifiedAt: Option.getOrElse(stat.mtime, () => new Date()),
meta,
} satisfies Project;
}),
); );
// Execute all effects in parallel and filter out nulls
const projectsWithNulls = yield* Effect.all(projectEffects, {
concurrency: "unbounded",
});
const projects = projectsWithNulls.filter(
(p): p is Project => p !== null,
);
// Sort by last modified date (newest first)
const sortedProjects = projects.sort((a, b) => {
return (
(b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0) -
(a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0)
);
});
return { projects: sortedProjects };
}); });
return { projects: sortedProjects }; return {
});
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly getProject: typeof getProject;
readonly getProjects: typeof getProjects;
}
>() {
static Live = Layer.succeed(this, {
getProject, getProject,
getProjects, getProjects,
}); };
});
export type IProjectRepository = InferEffect<typeof LayerImpl>;
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
IProjectRepository
>() {
static Live = Layer.effect(this, LayerImpl);
} }

View File

@@ -21,7 +21,7 @@ export type SSEEventDeclaration = {
processes: PublicSessionProcess[]; processes: PublicSessionProcess[];
}; };
permission_requested: { permissionRequested: {
permissionRequest: PermissionRequest; permissionRequest: PermissionRequest;
}; };
}; };