Merge pull request #33 from d-kimuson/feat/sep-fe-be-2
feat/sep fe be 2
2
.env.local.sample
Normal file
@@ -0,0 +1,2 @@
|
||||
DEV_FE_PORT=3400
|
||||
DEV_BE_PORT=3401
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
await import("./standalone/server.js").catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
72
docs/dev.md
@@ -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
|
||||
|
||||
@@ -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"]');
|
||||
|
||||
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 114 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 218 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 220 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 142 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 239 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 458 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 362 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 371 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 186 KiB After Width: | Height: | Size: 237 KiB |
|
Before Width: | Height: | Size: 187 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 343 KiB After Width: | Height: | Size: 370 KiB |
|
Before Width: | Height: | Size: 344 KiB After Width: | Height: | Size: 372 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 136 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 146 KiB |
|
Before Width: | Height: | Size: 128 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 105 KiB |
@@ -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
@@ -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>
|
||||
@@ -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;
|
||||
25
package.json
@@ -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
3
postcss.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"plugins": ["@tailwindcss/postcss"]
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -0,0 +1,10 @@
|
||||
declare module "process" {
|
||||
global {
|
||||
namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
DEV_BE_PORT?: string;
|
||||
PORT?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useSetAtom } from "jotai";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/projects");
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { AlertTriangle, ChevronDown, ExternalLink } from "lucide-react";
|
||||
import { type FC, useMemo } from "react";
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { Eye, MessageSquare } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useLingui } from "@lingui/react";
|
||||
import {
|
||||
ChevronDown,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, ChevronRightIcon, CopyIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import { Trans } from "@lingui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -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";
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||