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-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.85.5",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

36
pnpm-lock.yaml generated
View File

@@ -44,6 +44,9 @@ importers:
'@radix-ui/react-tabs':
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)
'@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':
specifier: ^5.85.5
version: 5.85.5(react@19.1.1)
@@ -1319,6 +1322,19 @@ packages:
'@types/react-dom':
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':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
@@ -4570,6 +4586,26 @@ snapshots:
'@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)':
dependencies:
react: 19.1.1

View File

@@ -175,6 +175,8 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
return (
<a
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"
{...props}
>

View File

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

View File

@@ -6,5 +6,5 @@ interface ProjectPageProps {
export default async function ProjectPage({ params }: ProjectPageProps) {
const { projectId } = await params;
redirect(`/projects/${encodeURIComponent(projectId)}/latest`);
redirect(`/projects/${projectId}/latest`);
}

View File

@@ -1,11 +1,24 @@
"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 { createPortal } from "react-dom";
import { NotificationSettings } from "@/components/NotificationSettings";
import { SettingsControls } from "@/components/SettingsControls";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab";
@@ -157,52 +170,89 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
>
{/* Tab Icons */}
<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={() => 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"
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="sessions-tab-button-mobile"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/projects"
className="w-12 h-12 flex items-center justify-center border-b border-sidebar-border 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>
<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"
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="mcp-tab-button-mobile"
>
<PlugIcon className="w-4 h-4" />
</button>
<div className="flex flex-col p-2 space-y-1">
<Tooltip>
<TooltipTrigger asChild>
<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"
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="sessions-tab-button-mobile"
>
<MessageSquareIcon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p></p>
</TooltipContent>
</Tooltip>
<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>
</div>
<Tooltip>
<TooltipTrigger asChild>
<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"
? "bg-sidebar-accent text-sidebar-accent-foreground shadow-sm"
: "text-sidebar-foreground/70",
)}
data-testid="mcp-tab-button-mobile"
>
<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>
{/* Content Area */}

View File

@@ -1,9 +1,16 @@
"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 { SidebarTab } from "@/components/GlobalSidebar";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab";
@@ -36,7 +43,7 @@ export const SessionSidebar: FC<{
{
id: "sessions",
icon: MessageSquareIcon,
title: "Sessions",
title: "セッション一覧を表示",
content: (
<SessionsTab
sessions={sessions.map((session) => ({
@@ -54,7 +61,7 @@ export const SessionSidebar: FC<{
{
id: "mcp",
icon: PlugIcon,
title: "MCP Servers",
title: "MCPサーバー設定を表示",
content: <McpTab projectId={projectId} />,
},
],
@@ -76,6 +83,23 @@ export const SessionSidebar: FC<{
projectId={projectId}
additionalTabs={additionalTabs}
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>

View File

@@ -99,9 +99,7 @@ export const SessionsTab: FC<{
return (
<Link
key={session.id}
href={`/projects/${projectId}/sessions/${encodeURIComponent(
session.id,
)}`}
href={`/projects/${projectId}/sessions/${session.id}`}
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",
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 className="pt-0">
<Button asChild className="w-full">
<Link href={`/projects/${encodeURIComponent(project.id)}/latest`}>
<Link href={`/projects/${project.id}/latest`}>
View Conversations
</Link>
</Button>

View File

@@ -3,6 +3,7 @@
import { HistoryIcon } from "lucide-react";
import { Suspense } from "react";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import { CreateProjectDialog } from "./components/CreateProjectDialog";
import { ProjectList } from "./components/ProjectList";
export const dynamic = "force-dynamic";
@@ -27,7 +28,10 @@ export default function ProjectsPage() {
<main>
<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
fallback={
<div className="flex items-center justify-center py-12">

View File

@@ -3,6 +3,12 @@
import type { LucideIcon } from "lucide-react";
import { SettingsIcon } from "lucide-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 { NotificationSettings } from "./NotificationSettings";
import { SettingsControls } from "./SettingsControls";
@@ -19,6 +25,7 @@ interface GlobalSidebarProps {
className?: string;
additionalTabs?: SidebarTab[];
defaultActiveTab?: string;
headerButton?: ReactNode;
}
export const GlobalSidebar: FC<GlobalSidebarProps> = ({
@@ -26,11 +33,12 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
className,
additionalTabs = [],
defaultActiveTab,
headerButton,
}) => {
const settingsTab: SidebarTab = {
id: "settings",
icon: SettingsIcon,
title: "Settings",
title: "表示と通知の設定",
content: (
<div className="h-full flex flex-col">
<div className="border-b border-sidebar-border p-4">
@@ -96,29 +104,39 @@ export const GlobalSidebar: FC<GlobalSidebarProps> = ({
>
{/* 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>
<TooltipProvider>
{headerButton && (
<div className="border-b border-sidebar-border">{headerButton}</div>
)}
<div className="flex flex-col p-2 space-y-1">
{allTabs.map((tab) => {
const Icon = tab.icon;
return (
<Tooltip key={tab.id}>
<TooltipTrigger asChild>
<button
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",
)}
data-testid={`${tab.id}-tab-button`}
>
<Icon className="w-4 h-4" />
</button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{tab.title}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
</TooltipProvider>
</div>
{/* 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);
// Listen for permission requests from the server
useServerEventListener("permission_requested", (data) => {
useServerEventListener("permissionRequested", (data) => {
if (data.permissionRequest) {
setCurrentPermissionRequest(data.permissionRequest);
setIsDialogOpen(true);

View File

@@ -23,7 +23,6 @@ export const makeQueryClient = () =>
defaultOptions: {
queries: {
refetchOnWindowFocus: true,
refetchInterval: 1000 * 60 * 5,
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 { honoClient } from "./client";
@@ -16,6 +17,22 @@ export const projectListQuery = {
},
} 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) =>
({
queryKey: ["projects", projectId],
@@ -70,7 +87,7 @@ export const sessionDetailQuery = (projectId: string, sessionId: string) =>
throw new Error(`Failed to fetch session: ${response.statusText}`);
}
return response.json();
return await response.json();
},
}) as const;

View File

@@ -1,4 +1,5 @@
import { readdir } from "node:fs/promises";
import { homedir } from "node:os";
import { resolve } from "node:path";
import type { CommandExecutor, FileSystem, Path } from "@effect/platform";
import { zValidator } from "@hono/zod-validator";
@@ -12,6 +13,8 @@ import { configSchema } from "../config/config";
import { env } from "../lib/env";
import { ClaudeCodeLifeCycleService } from "../service/claude-code/ClaudeCodeLifeCycleService";
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 { EventBus } from "../service/events/EventBus";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
@@ -22,6 +25,7 @@ import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList";
import { claudeCommandsDirPath } from "../service/paths";
import { encodeProjectId } from "../service/project/id";
import type { ProjectMetaService } from "../service/project/ProjectMetaService";
import { ProjectRepository } from "../service/project/ProjectRepository";
import type { SessionMetaService } from "../service/session/SessionMetaService";
@@ -92,6 +96,87 @@ export const routes = (app: HonoAppType) =>
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(
"/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })),
@@ -172,10 +257,11 @@ export const routes = (app: HonoAppType) =>
filteredSessions = Array.from(sessionMap.values());
}
const hasMore = sessions.length >= 20;
return {
project,
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);
if (result.status === 200) {
const { sessionId } =
await result.result.awaitSessionInitialized();
return c.json({
sessionProcess: {
id: result.result.sessionProcess.def.sessionProcessId,
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("sessionChanged", onSessionChanged);
yield* eventBus.on(
@@ -655,6 +754,10 @@ export const routes = (app: HonoAppType) =>
onSessionProcessChanged,
);
yield* eventBus.on("heartbeat", onHeartbeat);
yield* eventBus.on(
"permissionRequested",
onPermissionRequested,
);
const { connectionPromise } = adaptInternalEventToSSE(
rawStream,
@@ -676,6 +779,10 @@ export const routes = (app: HonoAppType) =>
onSessionProcessChanged,
);
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({
onNewUserMessageResolved: async (message) => {
@@ -194,7 +199,9 @@ const LayerImpl = Effect.gen(function* () {
// do nothing
}
sessionInitializedPromise.resolve(message.session_id);
sessionInitializedPromise.resolve({
sessionId: message.session_id,
});
yield* eventBusService.emit("sessionListChanged", {
projectId: processState.def.projectId,
@@ -216,6 +223,10 @@ const LayerImpl = Effect.gen(function* () {
sessionProcessId: processState.def.sessionProcessId,
});
sessionFileCreatedPromise.resolve({
sessionId: message.session_id,
});
yield* virtualConversationDatabase.deleteVirtualConversations(
message.session_id,
);
@@ -329,6 +340,8 @@ const LayerImpl = Effect.gen(function* () {
daemonPromise,
awaitSessionInitialized: async () =>
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 { describe, expect, it } from "vitest";
import { EventBus } from "../events/EventBus";
@@ -38,7 +41,6 @@ const createMockContinueTaskDef = (
baseSessionId,
});
// Helper function to create mock init context
const createMockInitContext = (sessionId: string): InitMessageContext => ({
initMessage: {
@@ -48,11 +50,12 @@ const createMockInitContext = (sessionId: string): InitMessageContext => ({
});
// Helper function to create mock result message
const createMockResultMessage = (sessionId: string): SDKResultMessage => ({
type: "result",
session_id: sessionId,
result: {},
} as SDKResultMessage);
const createMockResultMessage = (sessionId: string): SDKResultMessage =>
({
type: "result",
session_id: sessionId,
result: {},
}) as SDKResultMessage;
// Mock EventBus for testing
const MockEventBus = Layer.succeed(EventBus, {
@@ -581,7 +584,9 @@ describe("ClaudeCodeSessionProcessService", () => {
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");
if (completedTask?.status === "completed") {
expect(completedTask.sessionId).toBe("session-1");
@@ -758,9 +763,7 @@ describe("ClaudeCodeSessionProcessService", () => {
const program = Effect.gen(function* () {
const service = yield* ClaudeCodeSessionProcessService;
const result = yield* Effect.flip(
service.getTask("non-existent-task"),
);
const result = yield* Effect.flip(service.getTask("non-existent-task"));
return result;
});
@@ -857,7 +860,11 @@ describe("ClaudeCodeSessionProcessService", () => {
});
// 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({
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 { join } from "node:path";
import { Context, Effect, Layer, Ref } from "effect";
import z from "zod";
import { claudeProjectsDirPath } from "../paths";
import { encodeProjectIdFromSessionFilePath } from "../project/id";
import { EventBus } from "./EventBus";
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
@@ -54,8 +56,13 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
if (!groups.success) return;
const { projectId, sessionId } = groups.data;
const debounceKey = `${projectId}/${sessionId}`;
const { sessionId } = groups.data;
// フルパスを構築してエンコードされた projectId を取得
const fullPath = join(claudeProjectsDirPath, filename);
const encodedProjectId =
encodeProjectIdFromSessionFilePath(fullPath);
const debounceKey = `${encodedProjectId}/${sessionId}`;
Effect.runPromise(
Effect.gen(function* () {
@@ -68,14 +75,14 @@ export class FileWatcherService extends Context.Tag("FileWatcherService")<
const newTimer = setTimeout(() => {
Effect.runFork(
eventBus.emit("sessionChanged", {
projectId,
projectId: encodedProjectId,
sessionId,
}),
);
Effect.runFork(
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
ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
@@ -70,7 +69,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
const mockCwd = "/test/repo";
const mockOutput = "";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,
@@ -92,7 +90,6 @@ 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,
@@ -156,7 +153,6 @@ ghi789|chore: update deps|Bob Johnson|2024-01-13 08:10:00 +0900`;
it("Gitコマンドが失敗した場合", async () => {
const mockCwd = "/test/repo";
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: false,
error: {
@@ -206,7 +202,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
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,
@@ -233,7 +228,6 @@ def456|fix: 日本語メッセージ|日本語 著者|2024-01-14 09:20:00 +0900`
def456|fix: bug|Author|2024-01-14 09:20:00 +0900
`;
vi.mocked(utils.executeGitCommand).mockResolvedValue({
success: true,
data: mockOutput,

View File

@@ -1,109 +1,111 @@
import { resolve } from "node:path";
import { FileSystem } from "@effect/platform";
import { Context, Effect, Layer, Option } from "effect";
import type { InferEffect } from "../../lib/effect/types";
import { claudeProjectsDirPath } from "../paths";
import type { Project } from "../types";
import { decodeProjectId, encodeProjectId } from "./id";
import { ProjectMetaService } from "./ProjectMetaService";
const getProject = (projectId: string) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const projectMetaService = yield* ProjectMetaService;
const LayerImpl = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const projectMetaService = yield* ProjectMetaService;
const fullPath = decodeProjectId(projectId);
const getProject = (projectId: string) =>
Effect.gen(function* () {
const fullPath = decodeProjectId(projectId);
// Check if project directory exists
const exists = yield* fs.exists(fullPath);
if (!exists) {
return yield* Effect.fail(new Error("Project not found"));
}
// Check if project directory exists
const exists = yield* fs.exists(fullPath);
if (!exists) {
return yield* Effect.fail(new Error("Project not found"));
}
// Get file stats
const stat = yield* fs.stat(fullPath);
// Get file stats
const stat = yield* fs.stat(fullPath);
// Get project metadata
const meta = yield* projectMetaService.getProjectMeta(projectId);
// Get project metadata
const meta = yield* projectMetaService.getProjectMeta(projectId);
return {
project: {
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,
return {
project: {
id: projectId,
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)
const getProjects = () =>
Effect.gen(function* () {
// 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,
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 };
});
export class ProjectRepository extends Context.Tag("ProjectRepository")<
ProjectRepository,
{
readonly getProject: typeof getProject;
readonly getProjects: typeof getProjects;
}
>() {
static Live = Layer.succeed(this, {
return {
getProject,
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[];
};
permission_requested: {
permissionRequested: {
permissionRequest: PermissionRequest;
};
};