Merge pull request #33 from d-kimuson/feat/sep-fe-be-2

feat/sep fe be 2
This commit is contained in:
きむそん
2025-10-26 18:55:41 +09:00
committed by GitHub
158 changed files with 2433 additions and 1848 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": ""

6
dist/index.js vendored
View File

@@ -1,6 +0,0 @@
#!/usr/bin/env node
await import("./standalone/server.js").catch((error) => {
console.error(error);
process.exit(1);
});

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

View File

@@ -12,7 +12,10 @@ export const sessionDetailCapture = defineCapture({
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(1000);
await page.waitForSelector(
'[data-testid="sessions-tab-button-mobile"]',
{ state: "visible", timeout: 1000 },
);
const sessionsTabButton = page.locator(
'[data-testid="sessions-tab-button-mobile"]',
@@ -41,14 +44,16 @@ export const sessionDetailCapture = defineCapture({
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(1000);
await page.waitForSelector(
'[data-testid="settings-tab-button-mobile"]',
);
const settingsTabButton = page.locator(
'[data-testid="settings-tab-button-mobile"]',
);
if (await settingsTabButton.isVisible()) {
await settingsTabButton.click();
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
}
} else {
const settingsTabButton = page.locator(
@@ -56,7 +61,7 @@ export const sessionDetailCapture = defineCapture({
);
if (await settingsTabButton.isVisible()) {
await settingsTabButton.click();
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
}
}
},
@@ -70,19 +75,21 @@ export const sessionDetailCapture = defineCapture({
);
if (await menuButton.isVisible()) {
await menuButton.click();
await page.waitForTimeout(1000);
await page.waitForSelector(
'[data-testid="start-new-chat-button-mobile"]',
);
const startNewChatButton = page.locator(
'[data-testid="start-new-chat-button-mobile"]',
);
await startNewChatButton.click();
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
} else {
const startNewChatButton = page.locator(
'[data-testid="start-new-chat-button"]',
);
await startNewChatButton.click();
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
}
},
},
@@ -95,7 +102,7 @@ export const sessionDetailCapture = defineCapture({
.first();
if (await sidechainTaskButton.isVisible()) {
await sidechainTaskButton.click();
await page.waitForTimeout(1000);
await page.waitForSelector('[data-testid="sidechain-task-modal"]');
// モーダルが開いたことを確認
const modal = page.locator('[data-testid="sidechain-task-modal"]');

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -34,13 +34,13 @@ export const defineCapture = (options: {
await page.goto(href);
await page.waitForLoadState("domcontentloaded");
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
if (testCase) {
await testCase.setup(page);
}
await page.waitForTimeout(1000);
await page.waitForTimeout(2000);
const picturePath = testCase
? resolve(

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

@@ -15,17 +15,20 @@
"node": ">=20.19.0"
},
"exports": {
".": "./dist/index.js",
".": "./dist/main.js",
"./package.json": "./package.json"
},
"bin": {
"claude-code-viewer": "./dist/index.js"
"claude-code-viewer": "./dist/main.js"
},
"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 .",
@@ -38,7 +41,7 @@
"e2e": "./scripts/e2e/exec_e2e.sh",
"e2e:start-server": "./scripts/e2e/start_server.sh",
"e2e:capture-snapshots": "./scripts/e2e/capture_snapshots.sh",
"lingui:extract": "lingui extract --clean",
"lingui:extract": "lingui extract",
"lingui:compile": "lingui compile --typescript"
},
"dependencies": {
@@ -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;

View File

@@ -2,17 +2,10 @@
set -euxo pipefail
if [ -d "dist/.next" ]; then
rm -rf dist/.next
fi
if [ -d "dist/standalone" ]; then
rm -rf dist/standalone
if [ -d "dist" ]; then
rm -rf dist
fi
pnpm lingui:compile
pnpm exec next build
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
cp -r .next/standalone ./dist/
pnpm build:frontend
pnpm build:backend

View File

@@ -8,4 +8,4 @@ export GLOBAL_CLAUDE_DIR=$(git rev-parse --show-toplevel)/mock-global-claude-dir
echo "Check directory structure in $GLOBAL_CLAUDE_DIR:"
ls -l $GLOBAL_CLAUDE_DIR
node ./dist/index.js
node ./dist/main.js

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";
@@ -9,6 +6,7 @@ import {
oneLight,
} from "react-syntax-highlighter/dist/esm/styles/prism";
import remarkGfm from "remark-gfm";
import { useTheme } from "../../hooks/useTheme";
interface MarkdownContentProps {
content: string;

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,280 +1,31 @@
"use client";
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
GitCompareIcon,
LoaderIcon,
MenuIcon,
PauseIcon,
XIcon,
} from "lucide-react";
import type { FC } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
import { Button } from "@/components/ui/button";
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
import { DiffModal } from "./diffModal";
import { ContinueChat } from "./resumeChat/ContinueChat";
import { ResumeChat } from "./resumeChat/ResumeChat";
import { Suspense, useState } from "react";
import { Loading } from "@/components/Loading";
import { SessionPageMain } from "./SessionPageMain";
import { SessionSidebar } from "./sessionSidebar/SessionSidebar";
export const SessionPageContent: FC<{
projectId: string;
sessionId: string;
}> = ({ projectId, sessionId }) => {
const { session, conversations, getToolResult } = useSession(
projectId,
sessionId,
);
const { data: projectData } = useProject(projectId);
// biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page
const project = projectData.pages[0]!.project;
const abortTask = useMutation({
mutationFn: async (sessionProcessId: string) => {
const response = await honoClient.api.cc["session-processes"][
":sessionProcessId"
].abort.$post({
param: { sessionProcessId },
json: { projectId },
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
});
const sessionProcess = useSessionProcess();
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
const relatedSessionProcess = useMemo(
() => sessionProcess.getSessionProcess(sessionId),
[sessionProcess, sessionId],
);
// Set up task completion notifications
useTaskNotifications(relatedSessionProcess?.status === "running");
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理
useEffect(() => {
if (
relatedSessionProcess?.status === "running" &&
conversations.length !== previousConversationLength
) {
setPreviousConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
}
}
}, [
conversations,
relatedSessionProcess?.status,
previousConversationLength,
]);
return (
<>
<div className="flex h-screen max-h-screen overflow-hidden">
<SessionSidebar
currentSessionId={sessionId}
projectId={projectId}
isMobileOpen={isMobileSidebarOpen}
onMobileOpenChange={setIsMobileSidebarOpen}
/>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="px-2 sm:px-3 py-2 sm:py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0 min-w-0">
<div className="space-y-2 sm:space-y-3">
<div className="flex items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="sm"
className="md:hidden flex-shrink-0"
onClick={() => setIsMobileSidebarOpen(true)}
data-testid="mobile-sidebar-toggle-button"
>
<MenuIcon className="w-4 h-4" />
</Button>
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
{session.meta.firstUserMessage !== null
? firstUserMessageToTitle(session.meta.firstUserMessage)
: sessionId}
</h1>
</div>
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.claudeProjectPath && (
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
)}
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{sessionId}
</Badge>
</div>
{relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium">
<Trans
id="session.conversation.in.progress"
message="Conversation is in progress..."
/>
</p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full animate-pulse"
style={{ width: "70%" }}
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
{relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
<Trans
id="session.conversation.paused"
message="Conversation is paused..."
/>
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
</div>
</header>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0 min-w-0"
data-testid="scrollable-content"
>
<main className="w-full px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 relative z-5 min-w-0">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
{relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<LoaderIcon className="w-8 h-8 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
</div>
<p className="text-sm text-muted-foreground font-medium animate-pulse">
<Trans
id="session.processing"
message="Claude Code is processing..."
/>
</p>
</div>
</div>
)}
{relatedSessionProcess !== undefined ? (
<ContinueChat
projectId={projectId}
sessionId={sessionId}
sessionProcessId={relatedSessionProcess.id}
/>
) : (
<ResumeChat projectId={projectId} sessionId={sessionId} />
)}
</main>
</div>
</div>
{/* Fixed Diff Button */}
<Button
onClick={() => setIsDiffModalOpen(true)}
className="fixed bottom-15 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
size="lg"
>
<GitCompareIcon className="w-6 h-6" />
</Button>
{/* Diff Modal */}
<DiffModal
projectId={projectId}
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
/>
{/* Permission Dialog */}
<PermissionDialog
permissionRequest={currentPermissionRequest}
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
</>
<Suspense fallback={<Loading />}>
<SessionPageMain
projectId={projectId}
sessionId={sessionId}
setIsMobileSidebarOpen={setIsMobileSidebarOpen}
/>
</Suspense>
</div>
);
};

View File

@@ -0,0 +1,269 @@
import { Trans } from "@lingui/react";
import { useMutation } from "@tanstack/react-query";
import {
GitCompareIcon,
LoaderIcon,
MenuIcon,
PauseIcon,
XIcon,
} from "lucide-react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { PermissionDialog } from "@/components/PermissionDialog";
import { Button } from "@/components/ui/button";
import { usePermissionRequests } from "@/hooks/usePermissionRequests";
import { useTaskNotifications } from "@/hooks/useTaskNotifications";
import { Badge } from "../../../../../../components/ui/badge";
import { honoClient } from "../../../../../../lib/api/client";
import { useProject } from "../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../services/firstCommandToTitle";
import { useSession } from "../hooks/useSession";
import { useSessionProcess } from "../hooks/useSessionProcess";
import { ConversationList } from "./conversationList/ConversationList";
import { DiffModal } from "./diffModal";
import { ContinueChat } from "./resumeChat/ContinueChat";
import { ResumeChat } from "./resumeChat/ResumeChat";
export const SessionPageMain: FC<{
projectId: string;
sessionId: string;
setIsMobileSidebarOpen: (open: boolean) => void;
}> = ({ projectId, sessionId, setIsMobileSidebarOpen }) => {
const { session, conversations, getToolResult } = useSession(
projectId,
sessionId,
);
const { data: projectData } = useProject(projectId);
// biome-ignore lint/style/noNonNullAssertion: useSuspenseInfiniteQuery guarantees at least one page
const project = projectData.pages[0]!.project;
const { currentPermissionRequest, isDialogOpen, onPermissionResponse } =
usePermissionRequests();
const [isDiffModalOpen, setIsDiffModalOpen] = useState(false);
const abortTask = useMutation({
mutationFn: async (sessionProcessId: string) => {
const response = await honoClient.api.cc["session-processes"][
":sessionProcessId"
].abort.$post({
param: { sessionProcessId },
json: { projectId },
});
if (!response.ok) {
throw new Error(response.statusText);
}
return response.json();
},
});
const sessionProcess = useSessionProcess();
const relatedSessionProcess = useMemo(
() => sessionProcess.getSessionProcess(sessionId),
[sessionProcess, sessionId],
);
// Set up task completion notifications
useTaskNotifications(relatedSessionProcess?.status === "running");
const [previousConversationLength, setPreviousConversationLength] =
useState(0);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// 自動スクロール処理
useEffect(() => {
if (
relatedSessionProcess?.status === "running" &&
conversations.length !== previousConversationLength
) {
setPreviousConversationLength(conversations.length);
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
scrollContainer.scrollTo({
top: scrollContainer.scrollHeight,
behavior: "smooth",
});
}
}
}, [
conversations,
relatedSessionProcess?.status,
previousConversationLength,
]);
return (
<>
<div className="flex-1 flex flex-col min-h-0 min-w-0">
<header className="px-2 sm:px-3 py-2 sm:py-3 sticky top-0 z-10 bg-background w-full flex-shrink-0 min-w-0">
<div className="space-y-2 sm:space-y-3">
<div className="flex items-center gap-2 sm:gap-3">
<Button
variant="ghost"
size="sm"
className="md:hidden flex-shrink-0"
onClick={() => setIsMobileSidebarOpen(true)}
data-testid="mobile-sidebar-toggle-button"
>
<MenuIcon className="w-4 h-4" />
</Button>
<h1 className="text-lg sm:text-2xl md:text-3xl font-bold break-all overflow-ellipsis line-clamp-1 px-1 sm:px-5 min-w-0">
{session.meta.firstUserMessage !== null
? firstUserMessageToTitle(session.meta.firstUserMessage)
: sessionId}
</h1>
</div>
<div className="px-1 sm:px-5 flex flex-wrap items-center gap-1 sm:gap-2">
{project?.claudeProjectPath && (
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{project.meta.projectPath ?? project.claudeProjectPath}
</Badge>
)}
<Badge
variant="secondary"
className="h-6 sm:h-8 text-xs sm:text-sm flex items-center"
>
{sessionId}
</Badge>
</div>
{relatedSessionProcess?.status === "running" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-primary/10 border border-primary/20 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin text-primary" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium">
<Trans
id="session.conversation.in.progress"
message="Conversation is in progress..."
/>
</p>
<div className="w-full bg-primary/10 rounded-full h-1 mt-1 overflow-hidden">
<div
className="h-full bg-primary rounded-full animate-pulse"
style={{ width: "70%" }}
/>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
{relatedSessionProcess?.status === "paused" && (
<div className="flex items-center gap-1 sm:gap-2 p-1 bg-orange-50/80 dark:bg-orange-950/50 border border-orange-300/50 dark:border-orange-800/50 rounded-lg mx-1 sm:mx-5 animate-in fade-in slide-in-from-top-2 duration-300">
<PauseIcon className="w-3 h-3 sm:w-4 sm:h-4 text-orange-600 dark:text-orange-400 animate-pulse" />
<div className="flex-1">
<p className="text-xs sm:text-sm font-medium text-orange-900 dark:text-orange-200">
<Trans
id="session.conversation.paused"
message="Conversation is paused..."
/>
</p>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
abortTask.mutate(relatedSessionProcess.id);
}}
disabled={abortTask.isPending}
className="hover:bg-orange-100 dark:hover:bg-orange-900/50 text-orange-900 dark:text-orange-200"
>
{abortTask.isPending ? (
<LoaderIcon className="w-3 h-3 sm:w-4 sm:h-4 animate-spin" />
) : (
<XIcon className="w-3 h-3 sm:w-4 sm:h-4" />
)}
<span className="hidden sm:inline">
<Trans id="session.conversation.abort" message="Abort" />
</span>
</Button>
</div>
)}
</div>
</header>
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto min-h-0 min-w-0"
data-testid="scrollable-content"
>
<main className="w-full px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 relative z-5 min-w-0">
<ConversationList
conversations={conversations}
getToolResult={getToolResult}
/>
{relatedSessionProcess?.status === "running" && (
<div className="flex justify-start items-center py-8 animate-in fade-in duration-500">
<div className="flex flex-col items-center gap-4">
<div className="relative">
<LoaderIcon className="w-8 h-8 animate-spin text-primary" />
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
</div>
<p className="text-sm text-muted-foreground font-medium animate-pulse">
<Trans
id="session.processing"
message="Claude Code is processing..."
/>
</p>
</div>
</div>
)}
{relatedSessionProcess !== undefined ? (
<ContinueChat
projectId={projectId}
sessionId={sessionId}
sessionProcessId={relatedSessionProcess.id}
/>
) : (
<ResumeChat projectId={projectId} sessionId={sessionId} />
)}
</main>
</div>
</div>
{/* Fixed Diff Button */}
<Button
onClick={() => setIsDiffModalOpen(true)}
className="fixed bottom-15 right-6 w-14 h-14 rounded-full shadow-lg hover:shadow-xl transition-all duration-200 z-50"
size="lg"
>
<GitCompareIcon className="w-6 h-6" />
</Button>
{/* Diff Modal */}
<DiffModal
projectId={projectId}
isOpen={isDiffModalOpen}
onOpenChange={setIsDiffModalOpen}
/>
{/* Permission Dialog */}
<PermissionDialog
permissionRequest={currentPermissionRequest}
isOpen={isDialogOpen}
onResponse={onPermissionResponse}
/>
</>
);
};

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 {
@@ -20,6 +16,7 @@ import {
import type { ToolResultContent } from "@/lib/conversation-schema/content/ToolResultContentSchema";
import type { AssistantMessageContent } from "@/lib/conversation-schema/message/AssistantMessageSchema";
import { Button } from "../../../../../../../components/ui/button";
import { useTheme } from "../../../../../../../hooks/useTheme";
import type { SidechainConversation } from "../../../../../../../lib/conversation-schema";
import { MarkdownContent } from "../../../../../../components/MarkdownContent";
import { SidechainConversationModal } from "../conversationModal/SidechainConversationModal";
@@ -115,6 +112,7 @@ export const AssistantConversationContent: FC<{
variant="ghost"
size="sm"
className="h-auto py-1.5 px-3 text-xs hover:bg-blue-100 dark:hover:bg-blue-900/30 rounded-none flex items-center gap-1"
data-testid="sidechain-task-button"
>
<Eye className="h-3 w-3" />
<Trans
@@ -195,7 +193,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 } 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,10 +1,9 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react";
import type { FC } from "react";
import { Button } from "@/components/ui/button";
import { Loading } from "../../../../../../../components/Loading";
import { mcpListQuery } from "../../../../../../../lib/api/queries";
export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
@@ -15,9 +14,14 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
data: mcpData,
isLoading,
error,
isFetching,
} = useQuery({
queryKey: mcpListQuery(projectId).queryKey,
queryFn: mcpListQuery(projectId).queryFn,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchInterval: false,
});
const handleReload = () => {
@@ -38,11 +42,11 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
disabled={isLoading}
disabled={isLoading || isFetching}
title={i18n._("Reload MCP servers")}
>
<RefreshCwIcon
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
className={`w-3 h-3 ${isLoading || isFetching ? "animate-spin" : ""}`}
/>
</Button>
</div>
@@ -52,7 +56,7 @@ export const McpTab: FC<{ projectId: string }> = ({ projectId }) => {
{isLoading && (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">
<Trans id="common.loading" message="Loading..." />
<Loading />
</div>
</div>
)}

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";
@@ -23,7 +21,6 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { McpTab } from "./McpTab";
import { SessionsTab } from "./SessionsTab";
@@ -41,13 +38,6 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
onClose,
}) => {
const { i18n } = useLingui();
const {
data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const [activeTab, setActiveTab] = useState<
"sessions" | "mcp" | "settings" | "system-info"
>("sessions");
@@ -95,15 +85,8 @@ export const MobileSidebar: FC<MobileSidebarProps> = ({
case "sessions":
return (
<SessionsTab
sessions={sessions.map((session) => ({
...session,
lastModifiedAt: new Date(session.lastModifiedAt),
}))}
currentSessionId={currentSessionId}
projectId={projectId}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
isMobile={true}
/>
);
@@ -192,7 +175,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,10 +1,7 @@
"use client";
import { Trans, useLingui } from "@lingui/react";
import { EditIcon, PlusIcon, RefreshCwIcon, TrashIcon } from "lucide-react";
import { type FC, useState } from "react";
import { toast } from "sonner";
import { SchedulerJobDialog } from "@/components/scheduler/SchedulerJobDialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
@@ -23,13 +20,21 @@ import {
useSchedulerJobs,
useUpdateSchedulerJob,
} from "@/hooks/useScheduler";
import { Loading } from "../../../../../../../components/Loading";
import { SchedulerJobDialog } from "../scheduler/SchedulerJobDialog";
export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
projectId,
sessionId,
}) => {
const { i18n } = useLingui();
const { data: jobs, isLoading, error, refetch } = useSchedulerJobs();
const {
data: jobs,
isLoading,
isFetching,
error,
refetch,
} = useSchedulerJobs();
const createJob = useCreateSchedulerJob();
const updateJob = useUpdateSchedulerJob();
const deleteJob = useDeleteSchedulerJob();
@@ -166,11 +171,11 @@ export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
disabled={isLoading}
disabled={isLoading || isFetching}
title={i18n._({ id: "common.reload", message: "Reload" })}
>
<RefreshCwIcon
className={`w-3 h-3 ${isLoading ? "animate-spin" : ""}`}
className={`w-3 h-3 ${isLoading || isFetching ? "animate-spin" : ""}`}
/>
</Button>
<Button
@@ -196,7 +201,7 @@ export const SchedulerTab: FC<{ projectId: string; sessionId: string }> = ({
{isLoading && (
<div className="flex items-center justify-center h-32">
<div className="text-sm text-muted-foreground">
<Trans id="common.loading" message="Loading..." />
<Loading />
</div>
</div>
)}

View File

@@ -1,14 +1,12 @@
"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 FC, Suspense, useMemo } from "react";
import type { SidebarTab } from "@/components/GlobalSidebar";
import { GlobalSidebar } from "@/components/GlobalSidebar";
import {
@@ -18,7 +16,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { useProject } from "../../../../hooks/useProject";
import { Loading } from "../../../../../../../components/Loading";
import { McpTab } from "./McpTab";
import { MobileSidebar } from "./MobileSidebar";
import { SchedulerTab } from "./SchedulerTab";
@@ -37,14 +35,6 @@ export const SessionSidebar: FC<{
isMobileOpen = false,
onMobileOpenChange,
}) => {
const {
data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const additionalTabs: SidebarTab[] = useMemo(
() => [
{
@@ -52,17 +42,12 @@ export const SessionSidebar: FC<{
icon: MessageSquareIcon,
title: "Show session list",
content: (
<SessionsTab
sessions={sessions.map((session) => ({
...session,
lastModifiedAt: new Date(session.lastModifiedAt),
}))}
currentSessionId={currentSessionId}
projectId={projectId}
hasNextPage={hasNextPage}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
<Suspense fallback={<Loading />}>
<SessionsTab
currentSessionId={currentSessionId}
projectId={projectId}
/>
</Suspense>
),
},
{
@@ -80,14 +65,7 @@ export const SessionSidebar: FC<{
),
},
],
[
sessions,
currentSessionId,
projectId,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
],
[currentSessionId, projectId],
);
return (
@@ -103,7 +81,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,37 +1,31 @@
"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";
import { cn } from "@/lib/utils";
import { formatLocaleDate } from "../../../../../../../lib/date/formatLocaleDate";
import type { Session } from "../../../../../../../server/core/types";
import { useConfig } from "../../../../../../hooks/useConfig";
import { NewChatModal } from "../../../../components/newChat/NewChatModal";
import { useProject } from "../../../../hooks/useProject";
import { firstUserMessageToTitle } from "../../../../services/firstCommandToTitle";
import { sessionProcessesAtom } from "../../store/sessionProcessesAtom";
export const SessionsTab: FC<{
sessions: Session[];
currentSessionId: string;
projectId: string;
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
onLoadMore?: () => void;
isMobile?: boolean;
}> = ({
sessions,
currentSessionId,
projectId,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isMobile = false,
}) => {
}> = ({ currentSessionId, projectId, isMobile = false }) => {
const {
data: projectData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useProject(projectId);
const sessions = projectData.pages.flatMap((page) => page.sessions);
const sessionProcesses = useAtomValue(sessionProcessesAtom);
const { config } = useConfig();
@@ -63,8 +57,8 @@ export const SessionsTab: FC<{
}
// Then sort by lastModifiedAt (newest first)
const aTime = a.lastModifiedAt ? a.lastModifiedAt.getTime() : 0;
const bTime = b.lastModifiedAt ? b.lastModifiedAt.getTime() : 0;
const aTime = a.lastModifiedAt ? new Date(a.lastModifiedAt).getTime() : 0;
const bTime = b.lastModifiedAt ? new Date(b.lastModifiedAt).getTime() : 0;
return bTime - aTime;
});
@@ -116,7 +110,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 &&
@@ -165,10 +160,10 @@ export const SessionsTab: FC<{
})}
{/* Load More Button */}
{hasNextPage && onLoadMore && (
{hasNextPage && fetchNextPage && (
<div className="p-2">
<Button
onClick={onLoadMore}
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
variant="outline"
size="sm"

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>
);
}

Some files were not shown because too many files have changed in this diff Show More