Next.js App separates vite SPA and hono BE

This commit is contained in:
d-kimsuon
2025-10-26 13:29:21 +09:00
parent 380e7ed2e5
commit 3c00d9e651
86 changed files with 1749 additions and 1438 deletions

2
.env.local.sample Normal file
View File

@@ -0,0 +1,2 @@
DEV_FE_PORT=3400
DEV_BE_PORT=3401

View File

@@ -21,11 +21,11 @@
## Project Overview
Claude Code Viewer reads Claude Code session logs directly from JSONL files (`~/.claude/projects/`) with zero data loss. It's a web-based client built as a CLI tool serving a Next.js application.
Claude Code Viewer reads Claude Code session logs directly from JSONL files (`~/.claude/projects/`) with zero data loss. It's a web-based client built as a CLI tool serving a Vite application.
**Core Architecture**:
- Frontend: Next.js 15 + React 19 + TanStack Query
- Backend: Hono + Effect-TS (all business logic)
- Frontend: Vite + TanStack Router + React 19 + TanStack Query
- Backend: Hono (standalone server) + Effect-TS (all business logic)
- Data: Direct JSONL reads with strict Zod validation
- Real-time: Server-Sent Events (SSE) for live updates
@@ -54,10 +54,11 @@ pnpm test
## Key Directory Patterns
- `src/app/api/[[...route]]/` - Hono API entry point (all routes defined here)
- `src/server/hono/route.ts` - Hono API routes definition (all routes defined here)
- `src/server/core/` - Effect-TS business logic (domain modules: session, project, git, etc.)
- `src/lib/conversation-schema/` - Zod schemas for JSONL validation
- `src/testing/layers/` - Reusable Effect test layers (`testPlatformLayer` is the foundation)
- `src/routes/` - TanStack Router routes
## Coding Standards

View File

@@ -15,7 +15,8 @@
"!**/*.css",
"!dist",
"!playwright.config.ts",
"!src/lib/i18n/locales/*/messages.ts"
"!src/lib/i18n/locales/*/messages.ts",
"!src/routeTree.gen.ts"
]
},
"formatter": {
@@ -51,6 +52,12 @@
"linter": {
"enabled": false
}
},
{
"includes": ["**/*.config.ts"],
"linter": {
"enabled": false
}
}
]
}

View File

@@ -5,7 +5,7 @@
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""

View File

@@ -6,13 +6,13 @@ This document provides technical details for developers contributing to Claude C
### Frontend
- **Framework**: Next.js 15 (App Router)
- **Framework**: Vite + TanStack Router
- **UI Libraries**: React 19, Radix UI, Tailwind CSS
- **State Management**: Jotai (global state), TanStack Query (server state)
### Backend
- **API Framework**: Hono integrated with Next.js API Routes
- **API Framework**: Hono (standalone server with @hono/node-server)
- Type-safe communication via Hono RPC
- Validation using `@hono/zod-validator`
- **Effect-TS**: All backend business logic is implemented using Effect-TS
@@ -61,27 +61,21 @@ pnpm install
## Starting the Development Server
### Development Mode (with limitations)
### Development Mode
```bash
pnpm dev
```
The development server will start, but **with the following limitations**:
This command starts:
- Frontend: Vite development server (port 3400 by default)
- Backend: Node server with tsx watch (port 3401 by default)
#### Next.js Development Server Constraints
Both servers run simultaneously using `npm-run-all2` for parallel execution.
The Next.js development server behaves like an Edge Runtime where API Routes don't share memory space. This causes:
### Production Mode
1. **Initialization runs on every request**: Initialization occurs for each API request, degrading performance
2. **Session process continuation unavailable**: Cannot resume Paused sessions across different requests
3. **SSE connection and process management inconsistencies**: Events that should be notified via SSE aren't shared between processes
Therefore, **development mode is sufficient for UI verification and minor changes**, but **production build startup is essential for comprehensive testing of session process management and SSE integration**.
### Production Mode (Recommended)
For comprehensive functionality testing, build and run in production mode:
Build and run in production mode:
```bash
# Build
@@ -91,7 +85,12 @@ pnpm build
pnpm start
```
The built application is output to the `dist/` directory and started with `pnpm start`.
The built application is output to the `dist/` directory:
- `dist/static/` - Frontend static files (built by Vite)
- `dist/main.js` - Backend server (built by esbuild)
- `dist/index.js` - CLI entry point
The production server serves static files and handles API requests on a single port (3000 by default).
## Quality Assurance
@@ -164,25 +163,32 @@ When the `vrt` label is added to a PR, CI automatically captures and commits sna
```
src/
├── app/ # Next.js App Router
│ ├── api/[[...route]]/ # Hono API Routes
│ ├── components/ # Page-specific components
── projects/ # Project-related pages
│ └── ...
├── components/ # Shared UI components
├── lib/ # Frontend common logic
── api/ # API client (Hono RPC)
│ ├── sse/ # SSE connection management
├── routes/ # TanStack Router routes
│ ├── __root.tsx # Root route with providers
│ ├── index.tsx # Home route
── projects/ # Project-related routes
├── app/ # Shared components and hooks (legacy directory name)
├── components/ # Shared components
│ ├── hooks/ # Custom hooks
── projects/ # Project-related page components
├── components/ # UI components library
│ └── ui/ # shadcn/ui components
├── lib/ # Frontend common logic
│ ├── api/ # API client (Hono RPC)
│ ├── sse/ # SSE connection management
│ └── conversation-schema/ # Zod schemas for conversation logs
├── server/ # Backend implementation
│ ├── core/ # Core domain logic (Effect-TS)
│ │ ├── claude-code/ # Claude Code integration
│ │ ├── events/ # SSE event management
│ │ ├── session/ # Session management
├── server/ # Backend implementation
│ ├── core/ # Core domain logic (Effect-TS)
│ │ ├── claude-code/ # Claude Code integration
│ │ ├── events/ # SSE event management
│ │ ├── session/ # Session management
│ │ └── ...
│ ├── hono/ # Hono application
└── lib/ # Backend common utilities
└── testing/ # Test helpers and mocks
│ ├── hono/ # Hono application
│ ├── app.ts # Hono app instance
│ │ └── route.ts # API routes definition
│ ├── lib/ # Backend common utilities
│ └── main.ts # Server entry point
└── testing/ # Test helpers and mocks
```
## Development Tips

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Web Viewer for Claude Code history"
/>
<title>Claude Code Viewer</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@@ -1,10 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
typescript: {
ignoreBuildErrors: true, // typechecking should be separeted by build
},
};
export default nextConfig;

View File

@@ -23,9 +23,12 @@
},
"scripts": {
"dev": "run-p 'dev:*'",
"dev:next": "next dev --turbopack",
"dev:frontend": "vite",
"dev:backend": "NODE_ENV=development tsx watch src/server/main.ts --env-file-if-exists=.env.local",
"start": "node dist/index.js",
"build": "./scripts/build.sh",
"build:frontend": "vite build",
"build:backend": "esbuild src/server/main.ts --format=esm --bundle --packages=external --sourcemap --platform=node --outfile=dist/main.js",
"lint": "run-s 'lint:*'",
"lint:biome-format": "biome format .",
"lint:biome-lint": "biome check .",
@@ -46,6 +49,7 @@
"@anthropic-ai/claude-code": "^2.0.24",
"@effect/platform": "^0.92.1",
"@effect/platform-node": "^0.98.4",
"@hono/node-server": "^1.19.5",
"@hono/zod-validator": "^0.7.4",
"@lingui/core": "^5.5.1",
"@lingui/react": "^5.5.1",
@@ -58,7 +62,11 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.16",
"@tanstack/react-devtools": "^0.7.8",
"@tanstack/react-query": "^5.90.5",
"@tanstack/react-router": "^1.133.27",
"@tanstack/react-router-devtools": "^1.133.27",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
@@ -67,8 +75,6 @@
"hono": "^4.10.1",
"jotai": "^2.15.0",
"lucide-react": "^0.546.0",
"next": "15.5.6",
"next-themes": "^0.4.6",
"parse-git-diff": "^0.0.19",
"prexit": "^2.3.0",
"react": "^19.2.0",
@@ -88,19 +94,26 @@
"@lingui/conf": "^5.5.1",
"@lingui/format-json": "^5.5.1",
"@lingui/loader": "^5.5.1",
"@lingui/vite-plugin": "^5.5.1",
"@tailwindcss/postcss": "^4.1.15",
"@tanstack/router-plugin": "^1.133.27",
"@tsconfig/strictest": "^2.0.6",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"@types/react-syntax-highlighter": "^15.5.13",
"@vitejs/plugin-react-swc": "^4.2.0",
"dotenv": "^17.2.3",
"esbuild": "^0.25.11",
"npm-run-all2": "^8.0.4",
"playwright": "^1.56.1",
"release-it": "^19.0.5",
"release-it-pnpm": "^4.6.6",
"tailwindcss": "^4.1.15",
"tsx": "^4.20.6",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"vite": "^7.1.12",
"vitest": "^3.2.4"
},
"packageManager": "pnpm@10.18.3+sha512.bbd16e6d7286fd7e01f6b3c0b3c932cda2965c06a908328f74663f10a9aea51f1129eea615134bf992831b009eabe167ecb7008b597f40ff9bc75946aadfb08d"

1573
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

3
postcss.config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"plugins": ["@tailwindcss/postcss"]
}

View File

@@ -1,5 +0,0 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

10
src/@types/env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
declare module "process" {
global {
namespace NodeJS {
interface ProcessEnv {
DEV_BE_PORT?: string;
PORT?: string;
}
}
}
}

View File

@@ -1,82 +0,0 @@
import { NodeContext } from "@effect/platform-node";
import { Effect } from "effect";
import { handle } from "hono/vercel";
import { ClaudeCodeController } from "../../../server/core/claude-code/presentation/ClaudeCodeController";
import { ClaudeCodePermissionController } from "../../../server/core/claude-code/presentation/ClaudeCodePermissionController";
import { ClaudeCodeSessionProcessController } from "../../../server/core/claude-code/presentation/ClaudeCodeSessionProcessController";
import { ClaudeCodeLifeCycleService } from "../../../server/core/claude-code/services/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "../../../server/core/claude-code/services/ClaudeCodePermissionService";
import { ClaudeCodeService } from "../../../server/core/claude-code/services/ClaudeCodeService";
import { ClaudeCodeSessionProcessService } from "../../../server/core/claude-code/services/ClaudeCodeSessionProcessService";
import { SSEController } from "../../../server/core/events/presentation/SSEController";
import { FileWatcherService } from "../../../server/core/events/services/fileWatcher";
import { FileSystemController } from "../../../server/core/file-system/presentation/FileSystemController";
import { GitController } from "../../../server/core/git/presentation/GitController";
import { GitService } from "../../../server/core/git/services/GitService";
import { ProjectRepository } from "../../../server/core/project/infrastructure/ProjectRepository";
import { ProjectController } from "../../../server/core/project/presentation/ProjectController";
import { ProjectMetaService } from "../../../server/core/project/services/ProjectMetaService";
import { SchedulerConfigBaseDir } from "../../../server/core/scheduler/config";
import { SchedulerService } from "../../../server/core/scheduler/domain/Scheduler";
import { SchedulerController } from "../../../server/core/scheduler/presentation/SchedulerController";
import { SessionRepository } from "../../../server/core/session/infrastructure/SessionRepository";
import { VirtualConversationDatabase } from "../../../server/core/session/infrastructure/VirtualConversationDatabase";
import { SessionController } from "../../../server/core/session/presentation/SessionController";
import { SessionMetaService } from "../../../server/core/session/services/SessionMetaService";
import { honoApp } from "../../../server/hono/app";
import { InitializeService } from "../../../server/hono/initialize";
import { routes } from "../../../server/hono/route";
import { platformLayer } from "../../../server/lib/effect/layers";
const program = routes(honoApp);
await Effect.runPromise(
program
// 依存の浅い順にコンテナに pipe する必要がある
.pipe(
/** Presentation */
Effect.provide(ProjectController.Live),
Effect.provide(SessionController.Live),
Effect.provide(GitController.Live),
Effect.provide(ClaudeCodeController.Live),
Effect.provide(ClaudeCodeSessionProcessController.Live),
Effect.provide(ClaudeCodePermissionController.Live),
Effect.provide(FileSystemController.Live),
Effect.provide(SSEController.Live),
Effect.provide(SchedulerController.Live),
)
.pipe(
/** Application */
Effect.provide(InitializeService.Live),
Effect.provide(FileWatcherService.Live),
)
.pipe(
/** Domain */
Effect.provide(ClaudeCodeLifeCycleService.Live),
Effect.provide(ClaudeCodePermissionService.Live),
Effect.provide(ClaudeCodeSessionProcessService.Live),
Effect.provide(ClaudeCodeService.Live),
Effect.provide(GitService.Live),
Effect.provide(SchedulerService.Live),
Effect.provide(SchedulerConfigBaseDir.Live),
)
.pipe(
/** Infrastructure */
Effect.provide(ProjectRepository.Live),
Effect.provide(SessionRepository.Live),
Effect.provide(ProjectMetaService.Live),
Effect.provide(SessionMetaService.Live),
Effect.provide(VirtualConversationDatabase.Live),
)
.pipe(
/** Platform */
Effect.provide(platformLayer),
Effect.provide(NodeContext.layer),
),
);
export const GET = handle(honoApp);
export const POST = handle(honoApp);
export const PUT = handle(honoApp);
export const PATCH = handle(honoApp);
export const DELETE = handle(honoApp);

View File

@@ -1,6 +1,3 @@
"use client";
import { useTheme } from "next-themes";
import type { FC } from "react";
import Markdown from "react-markdown";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
@@ -19,7 +16,7 @@ export const MarkdownContent: FC<MarkdownContentProps> = ({
content,
className = "",
}) => {
const { resolvedTheme } = useTheme();
const resolvedTheme = "light" as "light" | "dark"; // TODO: 設定から取り出す
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
return (

View File

@@ -1,5 +1,3 @@
"use client";
import { AlertCircle, Home, RefreshCw } from "lucide-react";
import type { FC, PropsWithChildren } from "react";
import { ErrorBoundary } from "react-error-boundary";

View File

@@ -1,5 +1,3 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { useSetAtom } from "jotai";
import type { FC, PropsWithChildren } from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import { useSetAtom } from "jotai";
import { type FC, type PropsWithChildren, useEffect } from "react";
import type { PublicSessionProcess } from "../../types/session-process";

View File

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

View File

@@ -1,79 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import { Geist, Geist_Mono } from "next/font/google";
import { ThemeProvider } from "next-themes";
import { Toaster } from "../components/ui/sonner";
import { honoClient } from "../lib/api/client";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { configQuery } from "../lib/api/queries";
import { LinguiServerProvider } from "../lib/i18n/LinguiServerProvider";
import { SSEProvider } from "../lib/sse/components/SSEProvider";
import { getUserConfigOnServerComponent } from "../server/lib/config/getUserConfigOnServerComponent";
import { RootErrorBoundary } from "./components/RootErrorBoundary";
import { SSEEventListeners } from "./components/SSEEventListeners";
import { SyncSessionProcess } from "./components/SyncSessionProcess";
import "./globals.css";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata = {
title: "Claude Code Viewer",
description: "Web Viewer for Claude Code history",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const userConfig = await getUserConfigOnServerComponent();
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
queryKey: configQuery.queryKey,
queryFn: configQuery.queryFn,
});
const initSessionProcesses = await honoClient.api.cc["session-processes"]
.$get({})
.then((response) => response.json());
return (
<html lang={userConfig.locale} suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<LinguiServerProvider locale={userConfig.locale}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<RootErrorBoundary>
<QueryClientProviderWrapper>
<SSEProvider>
<SSEEventListeners>
<SyncSessionProcess
initProcesses={initSessionProcesses.processes}
>
{children}
</SyncSessionProcess>
</SSEEventListeners>
</SSEProvider>
</QueryClientProviderWrapper>
</RootErrorBoundary>
<Toaster position="top-right" />
</ThemeProvider>
</LinguiServerProvider>
</body>
</html>
);
}

View File

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

View File

@@ -1,5 +0,0 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/projects");
}

View File

@@ -1,12 +1,12 @@
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { useNavigate } from "@tanstack/react-router";
import { honoClient } from "../../../../../lib/api/client";
export const useCreateSessionProcessMutation = (
projectId: string,
onSuccess?: () => void,
) => {
const router = useRouter();
const navigate = useNavigate();
return useMutation({
mutationFn: async (options: {
@@ -36,9 +36,13 @@ export const useCreateSessionProcessMutation = (
},
onSuccess: async (response) => {
onSuccess?.();
router.push(
`/projects/${projectId}/sessions/${response.sessionProcess.sessionId}`,
);
navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: {
projectId: projectId,
sessionId: response.sessionProcess.sessionId,
},
});
},
});
};

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
@@ -96,7 +94,7 @@ export const SessionPageContent: FC<{
]);
return (
<>
<div className="flex h-screen max-h-screen overflow-hidden">
<SessionSidebar
currentSessionId={sessionId}
projectId={projectId}
@@ -275,6 +273,6 @@ export const SessionPageContent: FC<{
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
</>
</div>
);
};

View File

@@ -1,9 +1,5 @@
"use client";
import { Trans } from "@lingui/react";
import { ChevronDown, Eye, Lightbulb, Wrench } from "lucide-react";
import Image from "next/image";
import { useTheme } from "next-themes";
import type { FC } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
@@ -42,7 +38,7 @@ export const AssistantConversationContent: FC<{
getSidechainConversationByPrompt,
getSidechainConversations,
}) => {
const { resolvedTheme } = useTheme();
const resolvedTheme = "light" as "light" | "dark"; // TODO: 設定から取り出す
const syntaxTheme = resolvedTheme === "dark" ? oneDark : oneLight;
if (content.type === "text") {
return (
@@ -195,7 +191,7 @@ export const AssistantConversationContent: FC<{
toolResult.content.map((item) => {
if (item.type === "image") {
return (
<Image
<img
key={item.source.data}
src={`data:${item.source.media_type};base64,${item.source.data}`}
alt="Tool Result"

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { AlertTriangle, ChevronDown, ExternalLink } from "lucide-react";
import { type FC, useMemo } from "react";

View File

@@ -1,6 +1,5 @@
import { Trans } from "@lingui/react";
import { AlertCircle, Image as ImageIcon } from "lucide-react";
import Image from "next/image";
import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import {
@@ -54,7 +53,7 @@ export const UserConversationContent: FC<{
</CardHeader>
<CardContent>
<div className="rounded-lg border overflow-hidden bg-background">
<Image
<img
src={`data:${content.source.media_type};base64,${content.source.data}`}
alt="User uploaded content"
className="max-w-full h-auto max-h-96 object-contain"

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { Eye, MessageSquare } from "lucide-react";
import type { FC } from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import {
ChevronDown,

View File

@@ -1,5 +1,3 @@
"use client";
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
import type { FC } from "react";
import { useState } from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react";

View File

@@ -1,6 +1,5 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { Link } from "@tanstack/react-router";
import {
ArrowLeftIcon,
InfoIcon,
@@ -9,7 +8,6 @@ import {
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";
@@ -192,7 +190,7 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/projects"
to="/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" />

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { EditIcon, PlusIcon, RefreshCwIcon, TrashIcon } from "lucide-react";
import { type FC, useState } from "react";

View File

@@ -1,13 +1,11 @@
"use client";
import { Trans } from "@lingui/react";
import { Link } from "@tanstack/react-router";
import {
ArrowLeftIcon,
CalendarClockIcon,
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";
@@ -103,7 +101,7 @@ export const SessionSidebar: FC<{
<Tooltip>
<TooltipTrigger asChild>
<Link
href="/projects"
to="/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" />

View File

@@ -1,9 +1,7 @@
"use client";
import { Trans } from "@lingui/react";
import { Link } from "@tanstack/react-router";
import { useAtomValue } from "jotai";
import { MessageSquareIcon, PlusIcon } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -116,7 +114,8 @@ export const SessionsTab: FC<{
return (
<Link
key={session.id}
href={`/projects/${projectId}/sessions/${session.id}`}
to={"/projects/$projectId/sessions/$sessionId"}
params={{ projectId, sessionId: 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

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

View File

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

View File

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

View File

@@ -1,48 +0,0 @@
import { QueryClient } from "@tanstack/react-query";
import type { Metadata } from "next";
import {
projectDetailQuery,
sessionDetailQuery,
} from "../../../../../lib/api/queries";
import { initializeI18n } from "../../../../../lib/i18n/initializeI18n";
import { SessionPageContent } from "./components/SessionPageContent";
type PageParams = {
projectId: string;
sessionId: string;
};
export async function generateMetadata({
params,
}: {
params: Promise<PageParams>;
}): Promise<Metadata> {
const { projectId, sessionId } = await params;
const queryClient = new QueryClient();
await queryClient.prefetchQuery({
...sessionDetailQuery(projectId, sessionId),
});
await queryClient.prefetchQuery({
queryKey: projectDetailQuery(projectId).queryKey,
queryFn: projectDetailQuery(projectId).queryFn,
});
return {
title: `Session: ${sessionId.slice(0, 8)}...`,
description: `View conversation session ${projectId}/${sessionId}`,
};
}
interface SessionPageProps {
params: Promise<PageParams>;
}
export default async function SessionPage({ params }: SessionPageProps) {
const { projectId, sessionId } = await params;
await initializeI18n();
return <SessionPageContent projectId={projectId} sessionId={sessionId} />;
}

View File

@@ -1,9 +1,7 @@
"use client";
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
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";
@@ -22,7 +20,7 @@ import { DirectoryPicker } from "./DirectoryPicker";
export const CreateProjectDialog: FC = () => {
const [open, setOpen] = useState(false);
const [selectedPath, setSelectedPath] = useState<string>("");
const router = useRouter();
const navigate = useNavigate();
const createProjectMutation = useMutation({
mutationFn: async () => {
@@ -40,7 +38,13 @@ export const CreateProjectDialog: FC = () => {
onSuccess: (result) => {
toast.success("Project created successfully");
setOpen(false);
router.push(`/projects/${result.projectId}/sessions/${result.sessionId}`);
navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: {
projectId: result.projectId,
sessionId: result.sessionId,
},
});
},
onError: (error) => {

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { useQuery } from "@tanstack/react-query";
import { ChevronRight, Folder } from "lucide-react";

View File

@@ -1,8 +1,6 @@
"use client";
import { Trans } from "@lingui/react";
import { Link } from "@tanstack/react-router";
import { FolderIcon } from "lucide-react";
import Link from "next/link";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -74,7 +72,10 @@ export const ProjectList: FC = () => {
</CardContent>
<CardContent className="pt-0">
<Button asChild className="w-full">
<Link href={`/projects/${project.id}/latest`}>
<Link
to={"/projects/$projectId/latest"}
params={{ projectId: project.id }}
>
<Trans
id="project_list.view_conversations"
message="View Conversations"

View File

@@ -1,17 +1,11 @@
import { Trans } from "@lingui/react";
import { HistoryIcon } from "lucide-react";
import { Suspense } from "react";
import { type FC, Suspense } from "react";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import { initializeI18n } from "../../lib/i18n/initializeI18n";
import { CreateProjectDialog } from "./components/CreateProjectDialog";
import { ProjectList } from "./components/ProjectList";
export const dynamic = "force-dynamic";
export const fetchCache = "force-no-store";
export default async function ProjectsPage() {
await initializeI18n();
export const ProjectsPage: FC = () => {
return (
<div className="flex h-screen max-h-screen overflow-hidden">
<GlobalSidebar />
@@ -58,4 +52,4 @@ export default async function ProjectsPage() {
</div>
</div>
);
}
};

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import type { LucideIcon } from "lucide-react";
import { InfoIcon, SettingsIcon } from "lucide-react";

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { useAtom } from "jotai";
import { type FC, useCallback, useId } from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import { ChevronDown, ChevronRight, Copy } from "lucide-react";
import { useState } from "react";
import { Badge } from "@/components/ui/badge";

View File

@@ -1,8 +1,5 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { useQueryClient } from "@tanstack/react-query";
import { useTheme } from "next-themes";
import { type FC, useId } from "react";
import { useConfig } from "@/app/hooks/useConfig";
import { Checkbox } from "@/components/ui/checkbox";
@@ -35,7 +32,7 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
const themeId = useId();
const { config, updateConfig } = useConfig();
const queryClient = useQueryClient();
const { theme, setTheme } = useTheme();
const theme = "system"; // TODO: 設定から取り出す
const { i18n } = useLingui();
const handleHideNoUserMessageChange = async () => {
@@ -301,7 +298,12 @@ export const SettingsControls: FC<SettingsControlsProps> = ({
<Trans id="settings.theme" message="Theme" />
</label>
)}
<Select value={theme || "system"} onValueChange={setTheme}>
<Select
value={theme || "system"}
onValueChange={() => {
// TODO: 設定を更新する
}}
>
<SelectTrigger id={themeId} className="w-full">
<SelectValue placeholder={i18n._("Select theme")} />
</SelectTrigger>

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { useSuspenseQuery } from "@tanstack/react-query";
import { CheckCircle2, ChevronDown, ChevronRight, XCircle } from "lucide-react";

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans } from "@lingui/react";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";

View File

@@ -1,5 +1,3 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { type FC, useEffect, useState } from "react";
import { InlineCompletion } from "@/app/projects/[projectId]/components/chatForm/InlineCompletion";

View File

@@ -1,5 +1,3 @@
"use client";
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import type * as React from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import type * as React from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
function Collapsible({

View File

@@ -1,5 +1,3 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import type * as React from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
import type * as React from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import type * as React from "react";

View File

@@ -1,10 +1,7 @@
"use client";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme();
const theme = "system"; // TODO: 設定から取り出す
return (
<Sonner

View File

@@ -1,5 +1,3 @@
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";

View File

@@ -1,5 +1,3 @@
"use client";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import type * as React from "react";

View File

@@ -1,5 +1,3 @@
"use client";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { useServerEventListener } from "@/lib/sse/hook/useServerEventListener";

View File

@@ -1,5 +1,3 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type {
NewSchedulerJob,

View File

@@ -1,5 +1,3 @@
"use client";
import {
isServer,
QueryClient,

View File

@@ -1,10 +1,4 @@
import { hc } from "hono/client";
import type { RouteType } from "../../server/hono/route";
export const honoClient = hc<RouteType>(
typeof window === "undefined"
? // biome-ignore lint/complexity/useLiteralKeys: allow here
// biome-ignore lint/style/noProcessEnv: allow here
`http://localhost:${process.env["PORT"]}/`
: "/",
);
export const honoClient = hc<RouteType>("/");

View File

@@ -1,20 +0,0 @@
"use client";
import { type Messages, setupI18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { type FC, type PropsWithChildren, useState } from "react";
export const LinguiClientProvider: FC<
PropsWithChildren<{
initialLocale: string;
initialMessages: Messages;
}>
> = ({ children, initialLocale, initialMessages }) => {
const [i18n] = useState(() => {
return setupI18n({
locale: initialLocale,
messages: { [initialLocale]: initialMessages },
});
});
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
};

View File

@@ -0,0 +1,14 @@
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import type { FC, PropsWithChildren } from "react";
import { i18nMessages } from ".";
for (const { locale, messages } of i18nMessages) {
i18n.load(locale, messages);
}
i18n.activate("en");
export const LinguiClientProvider: FC<PropsWithChildren> = ({ children }) => {
return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
};

View File

@@ -1,23 +0,0 @@
import { setI18n } from "@lingui/react/server";
import { getI18nInstance } from "./index";
import { LinguiClientProvider } from "./LinguiClientProvider";
import type { SupportedLocale } from "./schema";
export async function LinguiServerProvider(props: {
locale: SupportedLocale;
children: React.ReactNode;
}) {
const { children, locale } = props;
const i18n = getI18nInstance(locale);
setI18n(i18n);
return (
<LinguiClientProvider
initialLocale={locale}
initialMessages={i18n.messages}
>
{children}
</LinguiClientProvider>
);
}

View File

@@ -1,50 +1,20 @@
import "server-only";
import { type I18n, type Messages, setupI18n } from "@lingui/core";
import type { Messages } from "@lingui/core";
import { messages as enMessages } from "./locales/en/messages";
import { messages as jaMessages } from "./locales/ja/messages";
import type { SupportedLocale } from "./schema";
const locales: SupportedLocale[] = ["ja", "en"];
export const locales: SupportedLocale[] = ["ja", "en"];
async function loadCatalog(locale: SupportedLocale): Promise<{
[k: string]: Messages;
}> {
const { messages } = await import(`./locales/${locale}/messages`);
return {
[locale]: messages,
};
}
const catalogs = await Promise.all(locales.map(loadCatalog));
export const allMessages = catalogs.reduce((acc, oneCatalog) => {
// biome-ignore lint/performance/noAccumulatingSpread: size is small
return { ...acc, ...oneCatalog };
}, {});
type AllI18nInstances = { [K in SupportedLocale]: I18n };
export const allI18nInstances = locales.reduce(
(acc: Partial<AllI18nInstances>, locale) => {
const messages = allMessages[locale] ?? {};
const i18n = setupI18n({
locale,
messages: { [locale]: messages },
});
// biome-ignore lint/performance/noAccumulatingSpread: size is small
return { ...acc, [locale]: i18n };
export const i18nMessages = [
{
locale: "ja",
messages: jaMessages,
},
{},
) as AllI18nInstances;
export const getI18nInstance = (locale: SupportedLocale): I18n => {
if (!allI18nInstances[locale]) {
console.warn(`No i18n instance found for locale "${locale}"`);
}
const instance = allI18nInstances[locale] ?? allI18nInstances.en;
if (instance === undefined) {
throw new Error(`No i18n instance found for locale "${locale}"`);
}
return instance;
};
{
locale: "en",
messages: enMessages,
},
] as const satisfies Array<{
locale: SupportedLocale;
messages: Messages;
}>;

View File

@@ -1,9 +0,0 @@
import { setI18n } from "@lingui/react/server";
import { getUserConfigOnServerComponent } from "../../server/lib/config/getUserConfigOnServerComponent";
import { getI18nInstance } from ".";
export const initializeI18n = async () => {
const userConfig = await getUserConfigOnServerComponent();
const i18n = getI18nInstance(userConfig.locale);
setI18n(i18n);
};

View File

@@ -1,5 +1,3 @@
"use client";
import { createContext, useContext } from "react";
import type { SSEEvent } from "../../types/sse";

View File

@@ -1,5 +1,3 @@
"use client";
import type { FC, PropsWithChildren } from "react";
import { ServerEventsProvider } from "./ServerEventsProvider";

32
src/main.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import { routeTree } from "./routeTree.gen";
import "./styles.css";
const router = createRouter({
routeTree,
context: {},
defaultPreload: "intent",
scrollRestoration: true,
defaultStructuralSharing: true,
defaultPreloadStaleTime: 0,
});
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
const rootElement = document.getElementById("app");
if (rootElement && !rootElement.innerHTML) {
const root = ReactDOM.createRoot(rootElement);
root.render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
);
}

129
src/routeTree.gen.ts Normal file
View File

@@ -0,0 +1,129 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from "./routes/__root";
import { Route as IndexRouteImport } from "./routes/index";
import { Route as ProjectsProjectIdLatestIndexRouteImport } from "./routes/projects/$projectId/latest/index";
import { Route as ProjectsProjectIdSessionsSessionIdIndexRouteImport } from "./routes/projects/$projectId/sessions/$sessionId/index";
import { Route as ProjectsIndexRouteImport } from "./routes/projects/index";
const IndexRoute = IndexRouteImport.update({
id: "/",
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
const ProjectsIndexRoute = ProjectsIndexRouteImport.update({
id: "/projects/",
path: "/projects/",
getParentRoute: () => rootRouteImport,
} as any);
const ProjectsProjectIdLatestIndexRoute =
ProjectsProjectIdLatestIndexRouteImport.update({
id: "/projects/$projectId/latest/",
path: "/projects/$projectId/latest/",
getParentRoute: () => rootRouteImport,
} as any);
const ProjectsProjectIdSessionsSessionIdIndexRoute =
ProjectsProjectIdSessionsSessionIdIndexRouteImport.update({
id: "/projects/$projectId/sessions/$sessionId/",
path: "/projects/$projectId/sessions/$sessionId/",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
"/projects": typeof ProjectsIndexRoute;
"/projects/$projectId/latest": typeof ProjectsProjectIdLatestIndexRoute;
"/projects/$projectId/sessions/$sessionId": typeof ProjectsProjectIdSessionsSessionIdIndexRoute;
}
export interface FileRoutesByTo {
"/": typeof IndexRoute;
"/projects": typeof ProjectsIndexRoute;
"/projects/$projectId/latest": typeof ProjectsProjectIdLatestIndexRoute;
"/projects/$projectId/sessions/$sessionId": typeof ProjectsProjectIdSessionsSessionIdIndexRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof IndexRoute;
"/projects/": typeof ProjectsIndexRoute;
"/projects/$projectId/latest/": typeof ProjectsProjectIdLatestIndexRoute;
"/projects/$projectId/sessions/$sessionId/": typeof ProjectsProjectIdSessionsSessionIdIndexRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths:
| "/"
| "/projects"
| "/projects/$projectId/latest"
| "/projects/$projectId/sessions/$sessionId";
fileRoutesByTo: FileRoutesByTo;
to:
| "/"
| "/projects"
| "/projects/$projectId/latest"
| "/projects/$projectId/sessions/$sessionId";
id:
| "__root__"
| "/"
| "/projects/"
| "/projects/$projectId/latest/"
| "/projects/$projectId/sessions/$sessionId/";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute;
ProjectsIndexRoute: typeof ProjectsIndexRoute;
ProjectsProjectIdLatestIndexRoute: typeof ProjectsProjectIdLatestIndexRoute;
ProjectsProjectIdSessionsSessionIdIndexRoute: typeof ProjectsProjectIdSessionsSessionIdIndexRoute;
}
declare module "@tanstack/react-router" {
interface FileRoutesByPath {
"/": {
id: "/";
path: "/";
fullPath: "/";
preLoaderRoute: typeof IndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/projects/": {
id: "/projects/";
path: "/projects";
fullPath: "/projects";
preLoaderRoute: typeof ProjectsIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/projects/$projectId/latest/": {
id: "/projects/$projectId/latest/";
path: "/projects/$projectId/latest";
fullPath: "/projects/$projectId/latest";
preLoaderRoute: typeof ProjectsProjectIdLatestIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/projects/$projectId/sessions/$sessionId/": {
id: "/projects/$projectId/sessions/$sessionId/";
path: "/projects/$projectId/sessions/$sessionId";
fullPath: "/projects/$projectId/sessions/$sessionId";
preLoaderRoute: typeof ProjectsProjectIdSessionsSessionIdIndexRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
ProjectsIndexRoute: ProjectsIndexRoute,
ProjectsProjectIdLatestIndexRoute: ProjectsProjectIdLatestIndexRoute,
ProjectsProjectIdSessionsSessionIdIndexRoute:
ProjectsProjectIdSessionsSessionIdIndexRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>();

37
src/routes/__root.tsx Normal file
View File

@@ -0,0 +1,37 @@
import { TanStackDevtools } from "@tanstack/react-devtools";
import { createRootRoute, Outlet } from "@tanstack/react-router";
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools";
import { RootErrorBoundary } from "../app/components/RootErrorBoundary";
import { SSEEventListeners } from "../app/components/SSEEventListeners";
import { Toaster } from "../components/ui/sonner";
import { QueryClientProviderWrapper } from "../lib/api/QueryClientProviderWrapper";
import { LinguiClientProvider } from "../lib/i18n/LinguiProvider";
import { SSEProvider } from "../lib/sse/components/SSEProvider";
export const Route = createRootRoute({
component: () => (
<RootErrorBoundary>
<QueryClientProviderWrapper>
<LinguiClientProvider>
<SSEProvider>
<SSEEventListeners>
<Outlet />
<TanStackDevtools
config={{
position: "bottom-right",
}}
plugins={[
{
name: "Tanstack Router",
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
</SSEEventListeners>
</SSEProvider>
</LinguiClientProvider>
</QueryClientProviderWrapper>
<Toaster position="top-right" />
</RootErrorBoundary>
),
});

10
src/routes/index.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { createFileRoute, useRouter } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: RouteComponent,
});
function RouteComponent() {
const router = useRouter();
router.navigate({ to: "/projects" });
}

View File

@@ -0,0 +1,55 @@
import {
createFileRoute,
useLoaderData,
useRouter,
} from "@tanstack/react-router";
import { honoClient } from "../../../../lib/api/client";
export const Route = createFileRoute("/projects/$projectId/latest/")({
component: RouteComponent,
loader: async ({ params }) => {
const { projectId } = params;
const response = await honoClient.api.projects[":projectId"][
"latest-session"
].$get({
param: { projectId },
});
if (!response.ok) {
return {
success: false,
message: `Failed to fetch latest session: ${response.statusText}`,
} as const;
}
return {
success: true,
projectId,
data: await response.json(),
} as const;
},
});
function RouteComponent() {
const router = useRouter();
const loaderData = useLoaderData({ from: "/projects/$projectId/latest/" });
if (!loaderData.success) {
return <div>{loaderData.message}</div>;
}
const latestSession = loaderData.data.latestSession;
if (latestSession === null) {
router.navigate({ to: "/projects" });
return null;
}
router.navigate({
to: "/projects/$projectId/sessions/$sessionId",
params: {
projectId: loaderData.projectId,
sessionId: latestSession.id,
},
});
return null;
}

View File

@@ -0,0 +1,18 @@
import { createFileRoute } from "@tanstack/react-router";
import { SessionPageContent } from "../../../../../app/projects/[projectId]/sessions/[sessionId]/components/SessionPageContent";
export const Route = createFileRoute(
"/projects/$projectId/sessions/$sessionId/",
)({
component: RouteComponent,
});
function RouteComponent() {
const params = Route.useParams();
return (
<SessionPageContent
projectId={params.projectId}
sessionId={params.sessionId}
/>
);
}

View File

@@ -0,0 +1,10 @@
import { createFileRoute } from "@tanstack/react-router";
import { ProjectsPage } from "../../app/projects/page";
export const Route = createFileRoute("/projects/")({
component: RouteComponent,
});
function RouteComponent() {
return <ProjectsPage />;
}

View File

@@ -7,6 +7,6 @@ export type HonoContext = {
};
};
export const honoApp = new Hono<HonoContext>().basePath("/api");
export const honoApp = new Hono<HonoContext>();
export type HonoAppType = typeof honoApp;

View File

@@ -91,13 +91,13 @@ export const routes = (app: HonoAppType) =>
})
// routes
.get("/config", async (c) => {
.get("/api/config", async (c) => {
return c.json({
config: c.get("userConfig"),
});
})
.put("/config", zValidator("json", userConfigSchema), async (c) => {
.put("/api/config", zValidator("json", userConfigSchema), async (c) => {
const { ...config } = c.req.valid("json");
setCookie(c, "ccv-config", JSON.stringify(config));
@@ -107,7 +107,7 @@ export const routes = (app: HonoAppType) =>
});
})
.get("/version", async (c) => {
.get("/api/version", async (c) => {
return c.json({
version: packageJson.version,
});
@@ -117,7 +117,7 @@ export const routes = (app: HonoAppType) =>
* ProjectController Routes
*/
.get("/projects", async (c) => {
.get("/api/projects", async (c) => {
const response = await effectToResponse(
c,
projectController.getProjects(),
@@ -126,7 +126,7 @@ export const routes = (app: HonoAppType) =>
})
.get(
"/projects/:projectId",
"/api/projects/:projectId",
zValidator("query", z.object({ cursor: z.string().optional() })),
async (c) => {
const response = await effectToResponse(
@@ -143,7 +143,7 @@ export const routes = (app: HonoAppType) =>
)
.post(
"/projects",
"/api/projects",
zValidator(
"json",
z.object({
@@ -163,7 +163,7 @@ export const routes = (app: HonoAppType) =>
},
)
.get("/projects/:projectId/latest-session", async (c) => {
.get("/api/projects/:projectId/latest-session", async (c) => {
const response = await effectToResponse(
c,
projectController
@@ -179,7 +179,7 @@ export const routes = (app: HonoAppType) =>
* SessionController Routes
*/
.get("/projects/:projectId/sessions/:sessionId", async (c) => {
.get("/api/projects/:projectId/sessions/:sessionId", async (c) => {
const response = await effectToResponse(
c,
sessionController
@@ -193,7 +193,7 @@ export const routes = (app: HonoAppType) =>
* GitController Routes
*/
.get("/projects/:projectId/git/branches", async (c) => {
.get("/api/projects/:projectId/git/branches", async (c) => {
const response = await effectToResponse(
c,
gitController
@@ -205,7 +205,7 @@ export const routes = (app: HonoAppType) =>
return response;
})
.get("/projects/:projectId/git/commits", async (c) => {
.get("/api/projects/:projectId/git/commits", async (c) => {
const response = await effectToResponse(
c,
gitController
@@ -218,7 +218,7 @@ export const routes = (app: HonoAppType) =>
})
.post(
"/projects/:projectId/git/diff",
"/api/projects/:projectId/git/diff",
zValidator(
"json",
z.object({
@@ -241,7 +241,7 @@ export const routes = (app: HonoAppType) =>
)
.post(
"/projects/:projectId/git/commit",
"/api/projects/:projectId/git/commit",
zValidator("json", CommitRequestSchema),
async (c) => {
const response = await effectToResponse(
@@ -258,7 +258,7 @@ export const routes = (app: HonoAppType) =>
)
.post(
"/projects/:projectId/git/push",
"/api/projects/:projectId/git/push",
zValidator("json", PushRequestSchema),
async (c) => {
const response = await effectToResponse(
@@ -275,7 +275,7 @@ export const routes = (app: HonoAppType) =>
)
.post(
"/projects/:projectId/git/commit-and-push",
"/api/projects/:projectId/git/commit-and-push",
zValidator("json", CommitRequestSchema),
async (c) => {
const response = await effectToResponse(
@@ -295,7 +295,7 @@ export const routes = (app: HonoAppType) =>
* ClaudeCodeController Routes
*/
.get("/projects/:projectId/claude-commands", async (c) => {
.get("/api/projects/:projectId/claude-commands", async (c) => {
const response = await effectToResponse(
c,
claudeCodeController.getClaudeCommands({
@@ -305,7 +305,7 @@ export const routes = (app: HonoAppType) =>
return response;
})
.get("/projects/:projectId/mcp/list", async (c) => {
.get("/api/projects/:projectId/mcp/list", async (c) => {
const response = await effectToResponse(
c,
claudeCodeController
@@ -317,7 +317,7 @@ export const routes = (app: HonoAppType) =>
return response;
})
.get("/cc/meta", async (c) => {
.get("/api/cc/meta", async (c) => {
const response = await effectToResponse(
c,
claudeCodeController
@@ -327,7 +327,7 @@ export const routes = (app: HonoAppType) =>
return response;
})
.get("/cc/features", async (c) => {
.get("/api/cc/features", async (c) => {
const response = await effectToResponse(
c,
claudeCodeController
@@ -341,7 +341,7 @@ export const routes = (app: HonoAppType) =>
* ClaudeCodeSessionProcessController Routes
*/
.get("/cc/session-processes", async (c) => {
.get("/api/cc/session-processes", async (c) => {
const response = await effectToResponse(
c,
claudeCodeSessionProcessController.getSessionProcesses(),
@@ -351,7 +351,7 @@ export const routes = (app: HonoAppType) =>
// new or resume
.post(
"/cc/session-processes",
"/api/cc/session-processes",
zValidator(
"json",
z.object({
@@ -373,7 +373,7 @@ export const routes = (app: HonoAppType) =>
// continue
.post(
"/cc/session-processes/:sessionProcessId/continue",
"/api/cc/session-processes/:sessionProcessId/continue",
zValidator(
"json",
z.object({
@@ -397,7 +397,7 @@ export const routes = (app: HonoAppType) =>
)
.post(
"/cc/session-processes/:sessionProcessId/abort",
"/api/cc/session-processes/:sessionProcessId/abort",
zValidator("json", z.object({ projectId: z.string() })),
async (c) => {
const { sessionProcessId } = c.req.param();
@@ -413,7 +413,7 @@ export const routes = (app: HonoAppType) =>
*/
.post(
"/cc/permission-response",
"/api/cc/permission-response",
zValidator(
"json",
z.object({
@@ -436,7 +436,7 @@ export const routes = (app: HonoAppType) =>
* SSEController Routes
*/
.get("/sse", async (c) => {
.get("/api/sse", async (c) => {
return streamSSE(
c,
async (rawStream) => {
@@ -456,7 +456,7 @@ export const routes = (app: HonoAppType) =>
* SchedulerController Routes
*/
.get("/scheduler/jobs", async (c) => {
.get("/api/scheduler/jobs", async (c) => {
const response = await effectToResponse(
c,
schedulerController.getJobs().pipe(Effect.provide(runtime)),
@@ -465,7 +465,7 @@ export const routes = (app: HonoAppType) =>
})
.post(
"/scheduler/jobs",
"/api/scheduler/jobs",
zValidator("json", newSchedulerJobSchema),
async (c) => {
const response = await effectToResponse(
@@ -481,7 +481,7 @@ export const routes = (app: HonoAppType) =>
)
.patch(
"/scheduler/jobs/:id",
"/api/scheduler/jobs/:id",
zValidator("json", updateSchedulerJobSchema),
async (c) => {
const response = await effectToResponse(
@@ -497,7 +497,7 @@ export const routes = (app: HonoAppType) =>
},
)
.delete("/scheduler/jobs/:id", async (c) => {
.delete("/api/scheduler/jobs/:id", async (c) => {
const response = await effectToResponse(
c,
schedulerController
@@ -514,12 +514,12 @@ export const routes = (app: HonoAppType) =>
*/
.get(
"/fs/file-completion",
"/api/fs/file-completion",
zValidator(
"query",
z.object({
projectId: z.string(),
basePath: z.string().optional().default("/"),
basePath: z.string().optional().default("/api/"),
}),
),
async (c) => {
@@ -535,7 +535,7 @@ export const routes = (app: HonoAppType) =>
)
.get(
"/fs/directory-browser",
"/api/fs/directory-browser",
zValidator(
"query",
z.object({

View File

@@ -1,8 +0,0 @@
import { cookies } from "next/headers";
import { parseUserConfig } from "./parseUserConfig";
export const getUserConfigOnServerComponent = async () => {
const cookie = await cookies();
const userConfigJson = cookie.get("ccv-config")?.value;
return parseUserConfig(userConfigJson);
};

117
src/server/main.ts Normal file
View File

@@ -0,0 +1,117 @@
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { NodeContext } from "@effect/platform-node";
import { serve } from "@hono/node-server";
import { serveStatic } from "@hono/node-server/serve-static";
import { Effect } from "effect";
import { ClaudeCodeController } from "./core/claude-code/presentation/ClaudeCodeController";
import { ClaudeCodePermissionController } from "./core/claude-code/presentation/ClaudeCodePermissionController";
import { ClaudeCodeSessionProcessController } from "./core/claude-code/presentation/ClaudeCodeSessionProcessController";
import { ClaudeCodeLifeCycleService } from "./core/claude-code/services/ClaudeCodeLifeCycleService";
import { ClaudeCodePermissionService } from "./core/claude-code/services/ClaudeCodePermissionService";
import { ClaudeCodeService } from "./core/claude-code/services/ClaudeCodeService";
import { ClaudeCodeSessionProcessService } from "./core/claude-code/services/ClaudeCodeSessionProcessService";
import { SSEController } from "./core/events/presentation/SSEController";
import { FileWatcherService } from "./core/events/services/fileWatcher";
import { FileSystemController } from "./core/file-system/presentation/FileSystemController";
import { GitController } from "./core/git/presentation/GitController";
import { GitService } from "./core/git/services/GitService";
import { ProjectRepository } from "./core/project/infrastructure/ProjectRepository";
import { ProjectController } from "./core/project/presentation/ProjectController";
import { ProjectMetaService } from "./core/project/services/ProjectMetaService";
import { SchedulerConfigBaseDir } from "./core/scheduler/config";
import { SchedulerService } from "./core/scheduler/domain/Scheduler";
import { SchedulerController } from "./core/scheduler/presentation/SchedulerController";
import { SessionRepository } from "./core/session/infrastructure/SessionRepository";
import { VirtualConversationDatabase } from "./core/session/infrastructure/VirtualConversationDatabase";
import { SessionController } from "./core/session/presentation/SessionController";
import { SessionMetaService } from "./core/session/services/SessionMetaService";
import { honoApp } from "./hono/app";
import { InitializeService } from "./hono/initialize";
import { routes } from "./hono/route";
import { platformLayer } from "./lib/effect/layers";
// biome-ignore lint/style/noProcessEnv: allow only here
const isDevelopment = process.env.NODE_ENV === "development";
if (!isDevelopment) {
const staticPath = resolve(import.meta.dirname, "static");
console.log("Serving static files from ", staticPath);
honoApp.use(
"/assets/*",
serveStatic({
root: staticPath,
}),
);
honoApp.use("*", async (c, next) => {
if (c.req.path.startsWith("/api")) {
return next();
}
const html = await readFile(resolve(staticPath, "index.html"), "utf-8");
return c.html(html);
});
}
const program = routes(honoApp)
// 依存の浅い順にコンテナに pipe する必要がある
.pipe(
/** Presentation */
Effect.provide(ProjectController.Live),
Effect.provide(SessionController.Live),
Effect.provide(GitController.Live),
Effect.provide(ClaudeCodeController.Live),
Effect.provide(ClaudeCodeSessionProcessController.Live),
Effect.provide(ClaudeCodePermissionController.Live),
Effect.provide(FileSystemController.Live),
Effect.provide(SSEController.Live),
Effect.provide(SchedulerController.Live),
)
.pipe(
/** Application */
Effect.provide(InitializeService.Live),
Effect.provide(FileWatcherService.Live),
)
.pipe(
/** Domain */
Effect.provide(ClaudeCodeLifeCycleService.Live),
Effect.provide(ClaudeCodePermissionService.Live),
Effect.provide(ClaudeCodeSessionProcessService.Live),
Effect.provide(ClaudeCodeService.Live),
Effect.provide(GitService.Live),
Effect.provide(SchedulerService.Live),
Effect.provide(SchedulerConfigBaseDir.Live),
)
.pipe(
/** Infrastructure */
Effect.provide(ProjectRepository.Live),
Effect.provide(SessionRepository.Live),
Effect.provide(ProjectMetaService.Live),
Effect.provide(SessionMetaService.Live),
Effect.provide(VirtualConversationDatabase.Live),
)
.pipe(
/** Platform */
Effect.provide(platformLayer),
Effect.provide(NodeContext.layer),
);
await Effect.runPromise(program);
const port = isDevelopment
? // biome-ignore lint/style/noProcessEnv: allow only here
(process.env.DEV_BE_PORT ?? "3401")
: // biome-ignore lint/style/noProcessEnv: allow only here
(process.env.PORT ?? "3000");
serve(
{
fetch: honoApp.fetch,
port: parseInt(port, 10),
},
(info) => {
console.log(`Server is running on http://localhost:${info.port}`);
},
);

View File

@@ -9,31 +9,17 @@
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
// build optimization
"incremental": true,
// plugins
"plugins": [
{
"name": "next"
}
],
"jsx": "react-jsx",
// paths alias
"paths": {
"@/*": ["./src/*"]
},
// typecheck
"types": ["node", "vitest/globals"],
"types": ["node", "vitest/globals", "vite/client"],
"exactOptionalPropertyTypes": false,
"noPropertyAccessFromIndexSignature": false,
"allowJs": true
},
"include": [
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"./.next/types/**/*.ts"
],
"include": ["src"],
"exclude": ["node_modules"]
}

35
vite.config.ts Normal file
View File

@@ -0,0 +1,35 @@
import { fileURLToPath, URL } from "node:url";
import { lingui } from "@lingui/vite-plugin";
import tailwindcss from "@tailwindcss/vite";
import { tanstackRouter } from "@tanstack/router-plugin/vite";
import viteReact from "@vitejs/plugin-react-swc";
import dotenv from "dotenv";
import { defineConfig } from "vite";
dotenv.config({ path: "../../.env.local" });
export default defineConfig({
plugins: [
tanstackRouter({
target: "react",
autoCodeSplitting: true,
}),
viteReact(),
lingui(),
tailwindcss(),
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
build: {
outDir: "dist/static",
},
server: {
port: parseInt(process.env.DEV_FE_PORT ?? "3400", 10),
proxy: {
"/api": `http://localhost:${process.env.DEV_BE_PORT ?? "3401"}`,
},
},
});