claude-session-viewer 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/bin/cli.js +48 -0
- package/bin/dev.js +37 -0
- package/index.html +13 -0
- package/package.json +67 -0
- package/postcss.config.js +6 -0
- package/src/App.tsx +174 -0
- package/src/components/ProjectGroup.tsx +132 -0
- package/src/components/SessionDetail.tsx +140 -0
- package/src/components/SessionList.tsx +45 -0
- package/src/index.css +55 -0
- package/src/main.tsx +10 -0
- package/src/server/index.ts +440 -0
- package/tailwind.config.js +11 -0
- package/tsconfig.json +31 -0
- package/tsconfig.node.json +10 -0
- package/vite.config.ts +32 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hanyeol Cho
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Claude Session Viewer
|
|
2
|
+
|
|
3
|
+
A web-based tool to visualize Claude session history from your local `.claude` directory in a timeline format.
|
|
4
|
+
|
|
5
|
+
## Getting Started
|
|
6
|
+
|
|
7
|
+
### Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Run with npx
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx .
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Or run with the package name:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx claude-session-viewer
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Run Development Server
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm run dev
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This command runs both:
|
|
32
|
+
- Backend server (http://localhost:3000)
|
|
33
|
+
- Frontend development server (http://localhost:5173)
|
|
34
|
+
|
|
35
|
+
Open http://localhost:5173 in your browser.
|
|
36
|
+
|
|
37
|
+
### Build
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
npm run build
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Features
|
|
44
|
+
|
|
45
|
+
- ✅ Auto-detect `.claude` directory
|
|
46
|
+
- ✅ Session list by project
|
|
47
|
+
- ✅ Session detail timeline view
|
|
48
|
+
- ✅ Real-time file change detection (WebSocket)
|
|
49
|
+
- 🚧 Search and filtering
|
|
50
|
+
- 🚧 Code highlighting
|
|
51
|
+
- 🚧 Markdown rendering
|
|
52
|
+
|
|
53
|
+
## Project Structure
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
.
|
|
57
|
+
├── src/
|
|
58
|
+
│ ├── server/ # Fastify backend
|
|
59
|
+
│ │ └── index.ts
|
|
60
|
+
│ ├── components/ # React components
|
|
61
|
+
│ │ ├── SessionList.tsx
|
|
62
|
+
│ │ └── SessionDetail.tsx
|
|
63
|
+
│ ├── App.tsx
|
|
64
|
+
│ ├── main.tsx
|
|
65
|
+
│ └── index.css
|
|
66
|
+
├── vite.config.ts # Vite config (includes proxy)
|
|
67
|
+
└── package.json
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## API
|
|
71
|
+
|
|
72
|
+
### GET /api/sessions
|
|
73
|
+
Returns a list of all sessions.
|
|
74
|
+
|
|
75
|
+
### GET /api/sessions/:id
|
|
76
|
+
Returns detailed information for a specific session.
|
|
77
|
+
|
|
78
|
+
### WebSocket /ws
|
|
79
|
+
Receives real-time file change events.
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
|
|
8
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
9
|
+
console.log(
|
|
10
|
+
[
|
|
11
|
+
"Claude Session Viewer",
|
|
12
|
+
"",
|
|
13
|
+
"Usage:",
|
|
14
|
+
" npx claude-session-viewer",
|
|
15
|
+
"",
|
|
16
|
+
"Options:",
|
|
17
|
+
" -h, --help Show this help message",
|
|
18
|
+
" -v, --version Show package version",
|
|
19
|
+
"",
|
|
20
|
+
].join("\n"),
|
|
21
|
+
);
|
|
22
|
+
process.exit(0);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (args.includes("--version") || args.includes("-v")) {
|
|
26
|
+
const pkg = await import("../package.json", { assert: { type: "json" } });
|
|
27
|
+
console.log(pkg.default.version);
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
33
|
+
|
|
34
|
+
const child = spawn(npmCmd, ["run", "dev"], {
|
|
35
|
+
cwd: rootDir,
|
|
36
|
+
stdio: "inherit",
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
child.on("exit", (code) => {
|
|
40
|
+
if (code !== null) {
|
|
41
|
+
process.exit(code);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
child.on("error", (error) => {
|
|
46
|
+
console.error("Failed to start dev servers:", error.message);
|
|
47
|
+
process.exit(1);
|
|
48
|
+
});
|
package/bin/dev.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import getPort from "get-port";
|
|
6
|
+
|
|
7
|
+
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
8
|
+
const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
|
|
9
|
+
|
|
10
|
+
const port = await getPort({ port: 3000 });
|
|
11
|
+
|
|
12
|
+
const children = new Set();
|
|
13
|
+
|
|
14
|
+
function spawnProcess(args, envOverrides) {
|
|
15
|
+
const child = spawn(npmCmd, args, {
|
|
16
|
+
cwd: rootDir,
|
|
17
|
+
stdio: "inherit",
|
|
18
|
+
env: { ...process.env, ...envOverrides },
|
|
19
|
+
});
|
|
20
|
+
children.add(child);
|
|
21
|
+
child.on("exit", () => {
|
|
22
|
+
children.delete(child);
|
|
23
|
+
});
|
|
24
|
+
return child;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
spawnProcess(["run", "dev:server"], { PORT: String(port) });
|
|
28
|
+
spawnProcess(["run", "dev:client"], { VITE_API_PORT: String(port) });
|
|
29
|
+
|
|
30
|
+
function shutdown(signal) {
|
|
31
|
+
for (const child of children) {
|
|
32
|
+
child.kill(signal);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
37
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
package/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
|
6
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
7
|
+
<title>Claude Session Viewer</title>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "claude-session-viewer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"claude-session-viewer": "./bin/cli.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "node bin/dev.js",
|
|
10
|
+
"dev:server": "tsx watch src/server/index.ts",
|
|
11
|
+
"dev:client": "vite",
|
|
12
|
+
"build": "tsc && vite build",
|
|
13
|
+
"preview": "vite preview",
|
|
14
|
+
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/hanyeol/claude-session-viewer.git"
|
|
19
|
+
},
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/hanyeol/claude-session-viewer/issues"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"claude",
|
|
25
|
+
"claude-code",
|
|
26
|
+
"session-viewer",
|
|
27
|
+
"cli"
|
|
28
|
+
],
|
|
29
|
+
"homepage": "https://github.com/hanyeol/claude-session-viewer",
|
|
30
|
+
"author": "Hanyeol Cho <hanyeol.cho@gmail.com>",
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@fastify/cors": "^9.0.1",
|
|
34
|
+
"@fastify/websocket": "^10.0.1",
|
|
35
|
+
"@tanstack/react-query": "^5.17.19",
|
|
36
|
+
"chokidar": "^3.5.3",
|
|
37
|
+
"date-fns": "^3.0.6",
|
|
38
|
+
"fastify": "^4.25.2",
|
|
39
|
+
"fuse.js": "^7.0.0",
|
|
40
|
+
"get-port": "^7.1.0",
|
|
41
|
+
"highlight.js": "^11.9.0",
|
|
42
|
+
"marked": "^11.1.1",
|
|
43
|
+
"react": "^18.2.0",
|
|
44
|
+
"react-dom": "^18.2.0",
|
|
45
|
+
"react-window": "^1.8.10",
|
|
46
|
+
"zustand": "^4.4.7"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20.10.6",
|
|
50
|
+
"@types/react": "^18.2.46",
|
|
51
|
+
"@types/react-dom": "^18.2.18",
|
|
52
|
+
"@types/react-window": "^1.8.8",
|
|
53
|
+
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
|
54
|
+
"@typescript-eslint/parser": "^6.16.0",
|
|
55
|
+
"@vitejs/plugin-react": "^4.2.1",
|
|
56
|
+
"autoprefixer": "^10.4.16",
|
|
57
|
+
"concurrently": "^8.2.2",
|
|
58
|
+
"eslint": "^8.56.0",
|
|
59
|
+
"eslint-plugin-react-hooks": "^4.6.0",
|
|
60
|
+
"eslint-plugin-react-refresh": "^0.4.5",
|
|
61
|
+
"postcss": "^8.4.32",
|
|
62
|
+
"tailwindcss": "^3.4.0",
|
|
63
|
+
"tsx": "^4.7.0",
|
|
64
|
+
"typescript": "^5.3.3",
|
|
65
|
+
"vite": "^5.0.10"
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { useState, useEffect } from 'react'
|
|
3
|
+
import SessionList from './components/SessionList'
|
|
4
|
+
import SessionDetail from './components/SessionDetail'
|
|
5
|
+
|
|
6
|
+
const queryClient = new QueryClient()
|
|
7
|
+
|
|
8
|
+
interface Session {
|
|
9
|
+
id: string
|
|
10
|
+
project: string
|
|
11
|
+
timestamp: string
|
|
12
|
+
messages: any[]
|
|
13
|
+
messageCount: number
|
|
14
|
+
title?: string
|
|
15
|
+
isAgent?: boolean
|
|
16
|
+
agentSessions?: Session[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ProjectGroup {
|
|
20
|
+
name: string
|
|
21
|
+
displayName: string
|
|
22
|
+
sessionCount: number
|
|
23
|
+
lastActivity: string
|
|
24
|
+
sessions: Session[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function AppContent() {
|
|
28
|
+
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null)
|
|
29
|
+
|
|
30
|
+
const { data, isLoading, error, refetch } = useQuery({
|
|
31
|
+
queryKey: ['sessions'],
|
|
32
|
+
queryFn: async () => {
|
|
33
|
+
const response = await fetch('/api/sessions')
|
|
34
|
+
if (!response.ok) throw new Error('Failed to fetch sessions')
|
|
35
|
+
return response.json() as Promise<{ projects: ProjectGroup[] }>
|
|
36
|
+
},
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// WebSocket connection for real-time updates
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
42
|
+
const wsUrl = `${protocol}//${window.location.host}/ws`
|
|
43
|
+
let ws: WebSocket | null = null
|
|
44
|
+
let closeAfterOpen = false
|
|
45
|
+
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
|
|
46
|
+
let retryCount = 0
|
|
47
|
+
let shouldReconnect = true
|
|
48
|
+
|
|
49
|
+
const connect = () => {
|
|
50
|
+
ws = new WebSocket(wsUrl)
|
|
51
|
+
closeAfterOpen = false
|
|
52
|
+
|
|
53
|
+
ws.onopen = () => {
|
|
54
|
+
if (closeAfterOpen) {
|
|
55
|
+
ws?.close()
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
retryCount = 0
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ws.onmessage = (event) => {
|
|
62
|
+
const message = JSON.parse(event.data)
|
|
63
|
+
if (message.type === 'file-added' || message.type === 'file-changed' || message.type === 'file-deleted') {
|
|
64
|
+
// Refetch session list
|
|
65
|
+
refetch()
|
|
66
|
+
|
|
67
|
+
// If a session is selected, also refetch its details
|
|
68
|
+
if (selectedSessionId) {
|
|
69
|
+
queryClient.invalidateQueries({ queryKey: ['session', selectedSessionId] })
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
ws.onerror = (error) => {
|
|
75
|
+
console.error('WebSocket error:', error)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
ws.onclose = () => {
|
|
79
|
+
if (!shouldReconnect) return
|
|
80
|
+
const delayMs = Math.min(1000 * 2 ** retryCount, 10000)
|
|
81
|
+
retryCount += 1
|
|
82
|
+
reconnectTimeout = setTimeout(connect, delayMs)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
connect()
|
|
87
|
+
|
|
88
|
+
return () => {
|
|
89
|
+
shouldReconnect = false
|
|
90
|
+
if (reconnectTimeout) {
|
|
91
|
+
clearTimeout(reconnectTimeout)
|
|
92
|
+
}
|
|
93
|
+
if (ws && ws.readyState === WebSocket.CONNECTING) {
|
|
94
|
+
closeAfterOpen = true
|
|
95
|
+
} else {
|
|
96
|
+
ws?.close()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}, [refetch, selectedSessionId])
|
|
100
|
+
|
|
101
|
+
if (isLoading) {
|
|
102
|
+
return (
|
|
103
|
+
<div className="flex items-center justify-center h-screen bg-gray-900">
|
|
104
|
+
<div className="text-white text-xl">Loading sessions...</div>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (error) {
|
|
110
|
+
return (
|
|
111
|
+
<div className="flex items-center justify-center h-screen bg-gray-900">
|
|
112
|
+
<div className="text-red-400 text-xl">Error: {error.message}</div>
|
|
113
|
+
</div>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="flex h-screen bg-gray-900 text-white">
|
|
119
|
+
{/* Sidebar */}
|
|
120
|
+
<div className="w-80 border-r border-gray-700 overflow-y-auto">
|
|
121
|
+
<div className="p-4 border-b border-gray-700">
|
|
122
|
+
<h1 className="text-xl font-bold">Claude Sessions</h1>
|
|
123
|
+
<p className="text-sm text-gray-400 mt-1">
|
|
124
|
+
{data?.projects.length || 0} project{data?.projects.length !== 1 ? 's' : ''}
|
|
125
|
+
</p>
|
|
126
|
+
</div>
|
|
127
|
+
<SessionList
|
|
128
|
+
projects={data?.projects || []}
|
|
129
|
+
selectedId={selectedSessionId}
|
|
130
|
+
onSelect={setSelectedSessionId}
|
|
131
|
+
/>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
{/* Main content */}
|
|
135
|
+
<div className="flex-1 overflow-y-auto">
|
|
136
|
+
{selectedSessionId ? (
|
|
137
|
+
<SessionDetail sessionId={selectedSessionId} />
|
|
138
|
+
) : (
|
|
139
|
+
<div className="flex items-center justify-center h-full text-gray-500">
|
|
140
|
+
<div className="text-center">
|
|
141
|
+
<svg
|
|
142
|
+
className="mx-auto h-12 w-12 text-gray-600"
|
|
143
|
+
fill="none"
|
|
144
|
+
viewBox="0 0 24 24"
|
|
145
|
+
stroke="currentColor"
|
|
146
|
+
>
|
|
147
|
+
<path
|
|
148
|
+
strokeLinecap="round"
|
|
149
|
+
strokeLinejoin="round"
|
|
150
|
+
strokeWidth={2}
|
|
151
|
+
d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
|
|
152
|
+
/>
|
|
153
|
+
</svg>
|
|
154
|
+
<h3 className="mt-2 text-sm font-medium">No session selected</h3>
|
|
155
|
+
<p className="mt-1 text-sm text-gray-600">
|
|
156
|
+
Select a session from the sidebar to view details
|
|
157
|
+
</p>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)}
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function App() {
|
|
167
|
+
return (
|
|
168
|
+
<QueryClientProvider client={queryClient}>
|
|
169
|
+
<AppContent />
|
|
170
|
+
</QueryClientProvider>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export default App
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import { format } from 'date-fns'
|
|
3
|
+
|
|
4
|
+
interface Session {
|
|
5
|
+
id: string
|
|
6
|
+
project: string
|
|
7
|
+
timestamp: string
|
|
8
|
+
messages: any[]
|
|
9
|
+
messageCount: number
|
|
10
|
+
title?: string
|
|
11
|
+
isAgent?: boolean
|
|
12
|
+
agentSessions?: Session[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ProjectGroupProps {
|
|
16
|
+
name: string
|
|
17
|
+
displayName: string
|
|
18
|
+
sessionCount: number
|
|
19
|
+
lastActivity: string
|
|
20
|
+
sessions: Session[]
|
|
21
|
+
selectedId: string | null
|
|
22
|
+
onSelectSession: (id: string) => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export default function ProjectGroup({
|
|
26
|
+
displayName,
|
|
27
|
+
sessionCount,
|
|
28
|
+
lastActivity,
|
|
29
|
+
sessions,
|
|
30
|
+
selectedId,
|
|
31
|
+
onSelectSession,
|
|
32
|
+
}: ProjectGroupProps) {
|
|
33
|
+
const [isExpanded, setIsExpanded] = useState(false)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="border-b border-gray-700">
|
|
37
|
+
{/* Project Header */}
|
|
38
|
+
<button
|
|
39
|
+
onClick={() => setIsExpanded(!isExpanded)}
|
|
40
|
+
className="sticky top-0 z-10 w-full text-left p-4 bg-gray-900 hover:bg-gray-800 transition-colors flex items-center justify-between"
|
|
41
|
+
>
|
|
42
|
+
<div className="flex-1 min-w-0">
|
|
43
|
+
<div className="flex items-center gap-2">
|
|
44
|
+
<svg
|
|
45
|
+
className={`w-4 h-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
|
46
|
+
fill="none"
|
|
47
|
+
viewBox="0 0 24 24"
|
|
48
|
+
stroke="currentColor"
|
|
49
|
+
>
|
|
50
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
51
|
+
</svg>
|
|
52
|
+
<span className="font-semibold text-sm truncate">{displayName}</span>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="text-xs text-gray-400 mt-1 ml-6">
|
|
55
|
+
{sessionCount} session{sessionCount !== 1 ? 's' : ''} · Last activity{' '}
|
|
56
|
+
{format(new Date(lastActivity), 'PPp')}
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
</button>
|
|
60
|
+
|
|
61
|
+
{/* Sessions List */}
|
|
62
|
+
{isExpanded && (
|
|
63
|
+
<div className="bg-gray-900/50">
|
|
64
|
+
{sessions.map((session) => (
|
|
65
|
+
<div key={session.id}>
|
|
66
|
+
{/* Main Session */}
|
|
67
|
+
<button
|
|
68
|
+
onClick={() => onSelectSession(session.id)}
|
|
69
|
+
className={`w-full text-left px-4 py-3 pl-8 hover:bg-gray-700/50 transition-colors border-l-2 ${
|
|
70
|
+
selectedId === session.id
|
|
71
|
+
? 'bg-gray-700 border-blue-500'
|
|
72
|
+
: 'border-transparent'
|
|
73
|
+
}`}
|
|
74
|
+
>
|
|
75
|
+
{session.title && (
|
|
76
|
+
<div className="text-sm font-medium text-gray-200 mb-1 truncate">
|
|
77
|
+
{session.title}
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
80
|
+
<div className="text-xs text-gray-400">
|
|
81
|
+
{format(new Date(session.timestamp), 'PPpp')}
|
|
82
|
+
</div>
|
|
83
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
84
|
+
{session.messageCount} message{session.messageCount !== 1 ? 's' : ''}
|
|
85
|
+
{session.agentSessions && session.agentSessions.length > 0 && (
|
|
86
|
+
<span className="ml-2">
|
|
87
|
+
· {session.agentSessions.length} task{session.agentSessions.length !== 1 ? 's' : ''}
|
|
88
|
+
</span>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</button>
|
|
92
|
+
|
|
93
|
+
{/* Agent Sessions */}
|
|
94
|
+
{session.agentSessions && session.agentSessions.length > 0 && (
|
|
95
|
+
<div className="bg-gray-900/70">
|
|
96
|
+
{session.agentSessions.map((agentSession) => (
|
|
97
|
+
<button
|
|
98
|
+
key={agentSession.id}
|
|
99
|
+
onClick={() => onSelectSession(agentSession.id)}
|
|
100
|
+
className={`w-full text-left px-4 py-2 pl-14 hover:bg-gray-700/50 transition-colors border-l-2 ${
|
|
101
|
+
selectedId === agentSession.id
|
|
102
|
+
? 'bg-gray-700 border-blue-500'
|
|
103
|
+
: 'border-transparent'
|
|
104
|
+
}`}
|
|
105
|
+
>
|
|
106
|
+
<div className="flex items-center gap-2 mb-1">
|
|
107
|
+
<span className="px-1.5 py-0.5 text-xs bg-purple-900/50 text-purple-300 rounded">
|
|
108
|
+
TASK
|
|
109
|
+
</span>
|
|
110
|
+
{agentSession.title && (
|
|
111
|
+
<span className="text-sm font-medium text-gray-300 truncate">
|
|
112
|
+
{agentSession.title}
|
|
113
|
+
</span>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
<div className="text-xs text-gray-400">
|
|
117
|
+
{format(new Date(agentSession.timestamp), 'PPpp')}
|
|
118
|
+
</div>
|
|
119
|
+
<div className="text-xs text-gray-500 mt-1">
|
|
120
|
+
{agentSession.messageCount} message{agentSession.messageCount !== 1 ? 's' : ''}
|
|
121
|
+
</div>
|
|
122
|
+
</button>
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
)}
|
|
126
|
+
</div>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { useQuery } from '@tanstack/react-query'
|
|
2
|
+
import { format } from 'date-fns'
|
|
3
|
+
|
|
4
|
+
interface SessionDetailProps {
|
|
5
|
+
sessionId: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function SessionDetail({ sessionId }: SessionDetailProps) {
|
|
9
|
+
const { data, isLoading, error } = useQuery({
|
|
10
|
+
queryKey: ['session', sessionId],
|
|
11
|
+
queryFn: async () => {
|
|
12
|
+
const response = await fetch(`/api/sessions/${sessionId}`)
|
|
13
|
+
if (!response.ok) throw new Error('Failed to fetch session')
|
|
14
|
+
return response.json()
|
|
15
|
+
},
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
if (isLoading) {
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex items-center justify-center h-full">
|
|
21
|
+
<div className="text-gray-400">Loading session...</div>
|
|
22
|
+
</div>
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (error) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex items-center justify-center h-full">
|
|
29
|
+
<div className="text-red-400">Error: {error.message}</div>
|
|
30
|
+
</div>
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const session = data?.session
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="h-full flex flex-col">
|
|
38
|
+
{/* Header */}
|
|
39
|
+
<div className="border-b border-gray-700 p-6 bg-gray-800">
|
|
40
|
+
<div className="flex items-center gap-2">
|
|
41
|
+
{session?.isAgent && (
|
|
42
|
+
<span className="px-2 py-1 text-xs bg-purple-900/50 text-purple-300 rounded font-semibold">
|
|
43
|
+
TASK
|
|
44
|
+
</span>
|
|
45
|
+
)}
|
|
46
|
+
<h2 className="text-2xl font-bold truncate flex-1">{session?.title || 'Untitled Session'}</h2>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="text-xl text-gray-300">{session?.project}</div>
|
|
49
|
+
<div className="flex items-center gap-3 mt-4 text-sm text-gray-400">
|
|
50
|
+
<span>
|
|
51
|
+
{session?.timestamp && format(new Date(session.timestamp), 'PPpp')}
|
|
52
|
+
</span>
|
|
53
|
+
<span>•</span>
|
|
54
|
+
<span>
|
|
55
|
+
{session?.messageCount || 0} message{session?.messageCount !== 1 ? 's' : ''}
|
|
56
|
+
</span>
|
|
57
|
+
{session?.agentSessions && session.agentSessions.length > 0 && (
|
|
58
|
+
<>
|
|
59
|
+
<span>•</span>
|
|
60
|
+
<span>
|
|
61
|
+
{session.agentSessions.length} task{session.agentSessions.length !== 1 ? 's' : ''}
|
|
62
|
+
</span>
|
|
63
|
+
</>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{/* Messages Timeline */}
|
|
69
|
+
<div className="flex-1 overflow-y-auto p-6">
|
|
70
|
+
<div className="max-w-4xl mx-auto space-y-6">
|
|
71
|
+
{session?.messages.map((message: any, index: number) => (
|
|
72
|
+
<div key={index} className="border-l-2 border-gray-700 pl-4">
|
|
73
|
+
<div className="flex items-start justify-between mb-2">
|
|
74
|
+
<div className="flex items-center gap-2">
|
|
75
|
+
<span
|
|
76
|
+
className={`px-2 py-1 text-xs rounded ${
|
|
77
|
+
message.type === 'user'
|
|
78
|
+
? 'bg-blue-900 text-blue-200'
|
|
79
|
+
: message.type === 'assistant'
|
|
80
|
+
? 'bg-green-900 text-green-200'
|
|
81
|
+
: 'bg-gray-700 text-gray-300'
|
|
82
|
+
}`}
|
|
83
|
+
>
|
|
84
|
+
{message.type || 'system'}
|
|
85
|
+
</span>
|
|
86
|
+
{message.timestamp && (
|
|
87
|
+
<span className="text-xs text-gray-500">
|
|
88
|
+
{format(new Date(message.timestamp), 'HH:mm:ss')}
|
|
89
|
+
</span>
|
|
90
|
+
)}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Message Content */}
|
|
95
|
+
<div className="bg-gray-800 rounded-lg p-4 text-sm">
|
|
96
|
+
{message.message?.content && Array.isArray(message.message.content) ? (
|
|
97
|
+
<div className="space-y-2">
|
|
98
|
+
{message.message.content.map((content: any, idx: number) => (
|
|
99
|
+
<div key={idx}>
|
|
100
|
+
{content.type === 'text' && (
|
|
101
|
+
<p className="whitespace-pre-wrap">{content.text}</p>
|
|
102
|
+
)}
|
|
103
|
+
{content.type === 'tool_use' && (
|
|
104
|
+
<div className="bg-gray-900 p-3 rounded border border-gray-700">
|
|
105
|
+
<div className="text-yellow-400 font-mono text-xs mb-2">
|
|
106
|
+
🔧 {content.name}
|
|
107
|
+
</div>
|
|
108
|
+
<pre className="text-xs overflow-x-auto text-gray-400">
|
|
109
|
+
{JSON.stringify(content.input, null, 2)}
|
|
110
|
+
</pre>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
{content.type === 'tool_result' && (
|
|
114
|
+
<div className="bg-gray-900 p-3 rounded border border-gray-700">
|
|
115
|
+
<div className="text-green-400 font-mono text-xs mb-2">
|
|
116
|
+
✓ Tool Result
|
|
117
|
+
</div>
|
|
118
|
+
<pre className="text-xs overflow-x-auto text-gray-400">
|
|
119
|
+
{typeof content.content === 'string'
|
|
120
|
+
? content.content
|
|
121
|
+
: JSON.stringify(content.content, null, 2)}
|
|
122
|
+
</pre>
|
|
123
|
+
</div>
|
|
124
|
+
)}
|
|
125
|
+
</div>
|
|
126
|
+
))}
|
|
127
|
+
</div>
|
|
128
|
+
) : (
|
|
129
|
+
<pre className="text-xs overflow-x-auto text-gray-400">
|
|
130
|
+
{JSON.stringify(message, null, 2)}
|
|
131
|
+
</pre>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import ProjectGroup from './ProjectGroup'
|
|
2
|
+
|
|
3
|
+
interface Session {
|
|
4
|
+
id: string
|
|
5
|
+
project: string
|
|
6
|
+
timestamp: string
|
|
7
|
+
messages: any[]
|
|
8
|
+
messageCount: number
|
|
9
|
+
title?: string
|
|
10
|
+
isAgent?: boolean
|
|
11
|
+
agentSessions?: Session[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ProjectGroup {
|
|
15
|
+
name: string
|
|
16
|
+
displayName: string
|
|
17
|
+
sessionCount: number
|
|
18
|
+
lastActivity: string
|
|
19
|
+
sessions: Session[]
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface SessionListProps {
|
|
23
|
+
projects: ProjectGroup[]
|
|
24
|
+
selectedId: string | null
|
|
25
|
+
onSelect: (id: string) => void
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default function SessionList({ projects, selectedId, onSelect }: SessionListProps) {
|
|
29
|
+
return (
|
|
30
|
+
<div>
|
|
31
|
+
{projects.map((project) => (
|
|
32
|
+
<ProjectGroup
|
|
33
|
+
key={project.name}
|
|
34
|
+
name={project.name}
|
|
35
|
+
displayName={project.displayName}
|
|
36
|
+
sessionCount={project.sessionCount}
|
|
37
|
+
lastActivity={project.lastActivity}
|
|
38
|
+
sessions={project.sessions}
|
|
39
|
+
selectedId={selectedId}
|
|
40
|
+
onSelectSession={onSelect}
|
|
41
|
+
/>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
)
|
|
45
|
+
}
|
package/src/index.css
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
@tailwind base;
|
|
2
|
+
@tailwind components;
|
|
3
|
+
@tailwind utilities;
|
|
4
|
+
|
|
5
|
+
:root {
|
|
6
|
+
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
|
7
|
+
line-height: 1.5;
|
|
8
|
+
font-weight: 400;
|
|
9
|
+
|
|
10
|
+
color-scheme: light dark;
|
|
11
|
+
color: rgba(255, 255, 255, 0.87);
|
|
12
|
+
background-color: #242424;
|
|
13
|
+
|
|
14
|
+
font-synthesis: none;
|
|
15
|
+
text-rendering: optimizeLegibility;
|
|
16
|
+
-webkit-font-smoothing: antialiased;
|
|
17
|
+
-moz-osx-font-smoothing: grayscale;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
body {
|
|
21
|
+
margin: 0;
|
|
22
|
+
display: flex;
|
|
23
|
+
place-items: center;
|
|
24
|
+
min-width: 320px;
|
|
25
|
+
min-height: 100vh;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
#root {
|
|
29
|
+
width: 100%;
|
|
30
|
+
min-height: 100vh;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* Custom scrollbar styles */
|
|
34
|
+
* {
|
|
35
|
+
scrollbar-width: thin;
|
|
36
|
+
scrollbar-color: #4B5563 #1F2937;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
*::-webkit-scrollbar {
|
|
40
|
+
width: 8px;
|
|
41
|
+
height: 8px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
*::-webkit-scrollbar-track {
|
|
45
|
+
background: #1F2937;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
*::-webkit-scrollbar-thumb {
|
|
49
|
+
background-color: #4B5563;
|
|
50
|
+
border-radius: 4px;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
*::-webkit-scrollbar-thumb:hover {
|
|
54
|
+
background-color: #6B7280;
|
|
55
|
+
}
|
package/src/main.tsx
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import Fastify from 'fastify'
|
|
2
|
+
import cors from '@fastify/cors'
|
|
3
|
+
import websocket from '@fastify/websocket'
|
|
4
|
+
import { homedir } from 'os'
|
|
5
|
+
import { join } from 'path'
|
|
6
|
+
import { readdir, readFile, stat } from 'fs/promises'
|
|
7
|
+
import chokidar from 'chokidar'
|
|
8
|
+
import getPort from 'get-port'
|
|
9
|
+
|
|
10
|
+
const CLAUDE_DIR = join(homedir(), '.claude')
|
|
11
|
+
const DEFAULT_PORT = 3000
|
|
12
|
+
|
|
13
|
+
const server = Fastify({
|
|
14
|
+
logger: true
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Plugins
|
|
18
|
+
await server.register(cors, {
|
|
19
|
+
origin: 'http://localhost:5173'
|
|
20
|
+
})
|
|
21
|
+
await server.register(websocket)
|
|
22
|
+
|
|
23
|
+
// Types
|
|
24
|
+
interface Session {
|
|
25
|
+
id: string
|
|
26
|
+
project: string
|
|
27
|
+
timestamp: string
|
|
28
|
+
messages: any[]
|
|
29
|
+
messageCount: number
|
|
30
|
+
title?: string
|
|
31
|
+
isAgent?: boolean
|
|
32
|
+
agentSessions?: Session[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ProjectGroup {
|
|
36
|
+
name: string
|
|
37
|
+
displayName: string
|
|
38
|
+
sessionCount: number
|
|
39
|
+
lastActivity: string
|
|
40
|
+
sessions: Session[]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Helper: Parse JSONL file
|
|
44
|
+
async function parseJsonl(filePath: string): Promise<any[]> {
|
|
45
|
+
const content = await readFile(filePath, 'utf-8')
|
|
46
|
+
return content
|
|
47
|
+
.split('\n')
|
|
48
|
+
.filter(line => line.trim())
|
|
49
|
+
.map(line => JSON.parse(line))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Helper: Clean text by removing tags
|
|
53
|
+
function cleanText(text: string): string {
|
|
54
|
+
return text
|
|
55
|
+
.replace(/<ide_selection>[\s\S]*?<\/ide_selection>/g, ' ')
|
|
56
|
+
.replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, ' ')
|
|
57
|
+
.replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
|
|
58
|
+
.replace(/\s+/g, ' ')
|
|
59
|
+
.trim()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function extractFirstText(content: any): string | null {
|
|
63
|
+
if (Array.isArray(content)) {
|
|
64
|
+
for (const item of content) {
|
|
65
|
+
if (item.type === 'text' && item.text) {
|
|
66
|
+
const cleaned = cleanText(item.text)
|
|
67
|
+
if (cleaned) {
|
|
68
|
+
return cleaned
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (typeof content === 'string') {
|
|
76
|
+
const cleaned = cleanText(content)
|
|
77
|
+
return cleaned || null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Helper: Extract title from session messages
|
|
84
|
+
function extractSessionTitle(messages: any[]): string {
|
|
85
|
+
// First, try to find queue-operation / enqueue message
|
|
86
|
+
for (const msg of messages) {
|
|
87
|
+
if (msg.type === 'queue-operation' && msg.operation === 'enqueue' && msg.content) {
|
|
88
|
+
const firstText = extractFirstText(msg.content)
|
|
89
|
+
if (firstText) {
|
|
90
|
+
return firstText.substring(0, 100).trim()
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Fallback: Find first user message with actual text content
|
|
96
|
+
for (const msg of messages) {
|
|
97
|
+
if (msg.type === 'user' && msg.message?.content) {
|
|
98
|
+
const firstText = extractFirstText(msg.message.content)
|
|
99
|
+
if (firstText) {
|
|
100
|
+
return firstText.substring(0, 100).trim()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return 'Untitled Session'
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function getProjectNameFromPath(projectPath: string): string {
|
|
109
|
+
return projectPath.split('/').pop()?.replace(/-Users-hanyeol-Projects-/, '') || 'unknown'
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function getProjectDisplayName(projectName: string): string {
|
|
113
|
+
return projectName.replace(/-Users-hanyeol-Projects-/, '')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function collectAgentDescriptions(messages: any[]): Map<string, string> {
|
|
117
|
+
const agentDescriptions = new Map<string, string>()
|
|
118
|
+
const toolUseDescriptions = new Map<string, string>()
|
|
119
|
+
const toolResultAgentIds = new Map<string, string>()
|
|
120
|
+
|
|
121
|
+
for (const msg of messages) {
|
|
122
|
+
if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
|
|
123
|
+
for (const item of msg.message.content) {
|
|
124
|
+
if (item.type === 'tool_use' && item.name === 'Task' && item.input?.description) {
|
|
125
|
+
toolUseDescriptions.set(item.id, item.input.description)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const agentId = msg.agentId || msg.toolUseResult?.agentId
|
|
131
|
+
if (agentId && msg.message?.content && Array.isArray(msg.message.content)) {
|
|
132
|
+
for (const item of msg.message.content) {
|
|
133
|
+
if (item.type === 'tool_result' && item.tool_use_id) {
|
|
134
|
+
toolResultAgentIds.set(item.tool_use_id, agentId)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const [toolUseId, description] of toolUseDescriptions.entries()) {
|
|
141
|
+
const agentId = toolResultAgentIds.get(toolUseId)
|
|
142
|
+
if (agentId) {
|
|
143
|
+
agentDescriptions.set(`agent-${agentId}`, description)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return agentDescriptions
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function attachAgentSessionsFromMap(
|
|
151
|
+
session: Session,
|
|
152
|
+
agentDescriptions: Map<string, string>,
|
|
153
|
+
agentSessionsMap: Map<string, Session>
|
|
154
|
+
): void {
|
|
155
|
+
if (agentDescriptions.size === 0) return
|
|
156
|
+
|
|
157
|
+
session.agentSessions = []
|
|
158
|
+
for (const [agentSessionId, description] of agentDescriptions) {
|
|
159
|
+
const agentSession = agentSessionsMap.get(agentSessionId)
|
|
160
|
+
if (agentSession) {
|
|
161
|
+
agentSession.title = description
|
|
162
|
+
session.agentSessions.push(agentSession)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
session.agentSessions.sort((a, b) =>
|
|
166
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function loadAgentSessionsFromFiles(
|
|
171
|
+
projectPath: string,
|
|
172
|
+
projectName: string,
|
|
173
|
+
agentDescriptions: Map<string, string>
|
|
174
|
+
): Promise<Session[]> {
|
|
175
|
+
const agentSessions: Session[] = []
|
|
176
|
+
|
|
177
|
+
for (const [agentSessionId, description] of agentDescriptions) {
|
|
178
|
+
const agentFile = join(projectPath, `${agentSessionId}.jsonl`)
|
|
179
|
+
try {
|
|
180
|
+
const agentMessages = await parseJsonl(agentFile)
|
|
181
|
+
const agentFileStat = await stat(agentFile)
|
|
182
|
+
agentSessions.push({
|
|
183
|
+
id: agentSessionId,
|
|
184
|
+
project: projectName,
|
|
185
|
+
timestamp: agentFileStat.mtime.toISOString(),
|
|
186
|
+
messages: agentMessages,
|
|
187
|
+
messageCount: agentMessages.length,
|
|
188
|
+
title: description,
|
|
189
|
+
isAgent: true
|
|
190
|
+
})
|
|
191
|
+
} catch {
|
|
192
|
+
// Skip if agent file not found
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
agentSessions.sort((a, b) =>
|
|
197
|
+
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
|
198
|
+
)
|
|
199
|
+
return agentSessions
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function findAgentTitleFromParentMessages(messages: any[], agentId: string): string | null {
|
|
203
|
+
const agentDescriptions = collectAgentDescriptions(messages)
|
|
204
|
+
const description = agentDescriptions.get(`agent-${agentId}`)
|
|
205
|
+
return description || null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Helper: Get all sessions from a project directory
|
|
209
|
+
async function getProjectSessions(projectPath: string): Promise<Session[]> {
|
|
210
|
+
const files = await readdir(projectPath)
|
|
211
|
+
const allSessions: Session[] = []
|
|
212
|
+
const agentSessionsMap = new Map<string, Session>()
|
|
213
|
+
|
|
214
|
+
// First pass: collect all sessions
|
|
215
|
+
for (const file of files) {
|
|
216
|
+
if (file.endsWith('.jsonl')) {
|
|
217
|
+
const filePath = join(projectPath, file)
|
|
218
|
+
const fileStat = await stat(filePath)
|
|
219
|
+
|
|
220
|
+
// Skip empty files
|
|
221
|
+
if (fileStat.size === 0) continue
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
const messages = await parseJsonl(filePath)
|
|
225
|
+
|
|
226
|
+
// Filter: Skip sessions with only 1 message that is assistant-only
|
|
227
|
+
if (messages.length === 1 && messages[0].type === 'assistant') {
|
|
228
|
+
continue
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Extract project name from path
|
|
232
|
+
const projectName = getProjectNameFromPath(projectPath)
|
|
233
|
+
|
|
234
|
+
// Extract session title
|
|
235
|
+
const title = extractSessionTitle(messages)
|
|
236
|
+
|
|
237
|
+
const sessionId = file.replace('.jsonl', '')
|
|
238
|
+
const isAgent = sessionId.startsWith('agent-')
|
|
239
|
+
|
|
240
|
+
const session: Session = {
|
|
241
|
+
id: sessionId,
|
|
242
|
+
project: projectName,
|
|
243
|
+
timestamp: fileStat.mtime.toISOString(),
|
|
244
|
+
messages,
|
|
245
|
+
messageCount: messages.length,
|
|
246
|
+
title,
|
|
247
|
+
isAgent
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (isAgent) {
|
|
251
|
+
agentSessionsMap.set(sessionId, session)
|
|
252
|
+
} else {
|
|
253
|
+
allSessions.push(session)
|
|
254
|
+
}
|
|
255
|
+
} catch (error) {
|
|
256
|
+
console.error(`Error parsing ${file}:`, error)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Second pass: attach agent sessions to their parent sessions
|
|
262
|
+
for (const session of allSessions) {
|
|
263
|
+
const agentDescriptions = collectAgentDescriptions(session.messages)
|
|
264
|
+
attachAgentSessionsFromMap(session, agentDescriptions, agentSessionsMap)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return allSessions
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// API: Get all sessions grouped by project
|
|
271
|
+
server.get('/api/sessions', async (request, reply) => {
|
|
272
|
+
try {
|
|
273
|
+
const projectsDir = join(CLAUDE_DIR, 'projects')
|
|
274
|
+
const projects = await readdir(projectsDir)
|
|
275
|
+
|
|
276
|
+
const projectGroups: ProjectGroup[] = []
|
|
277
|
+
|
|
278
|
+
for (const project of projects) {
|
|
279
|
+
const projectPath = join(projectsDir, project)
|
|
280
|
+
const projectStat = await stat(projectPath)
|
|
281
|
+
|
|
282
|
+
if (projectStat.isDirectory()) {
|
|
283
|
+
const sessions = await getProjectSessions(projectPath)
|
|
284
|
+
|
|
285
|
+
if (sessions.length > 0) {
|
|
286
|
+
// Sort sessions by timestamp descending
|
|
287
|
+
sessions.sort((a, b) =>
|
|
288
|
+
new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
const displayName = getProjectDisplayName(project)
|
|
292
|
+
|
|
293
|
+
projectGroups.push({
|
|
294
|
+
name: project,
|
|
295
|
+
displayName,
|
|
296
|
+
sessionCount: sessions.length,
|
|
297
|
+
lastActivity: sessions[0].timestamp, // Most recent session
|
|
298
|
+
sessions
|
|
299
|
+
})
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Sort project groups by last activity descending
|
|
305
|
+
projectGroups.sort((a, b) =>
|
|
306
|
+
new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime()
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
return { projects: projectGroups }
|
|
310
|
+
} catch (error) {
|
|
311
|
+
console.error('Error reading sessions:', error)
|
|
312
|
+
return { projects: [] }
|
|
313
|
+
}
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
// API: Get session by ID
|
|
317
|
+
server.get<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
|
|
318
|
+
try {
|
|
319
|
+
const { id } = request.params
|
|
320
|
+
const projectsDir = join(CLAUDE_DIR, 'projects')
|
|
321
|
+
const projects = await readdir(projectsDir)
|
|
322
|
+
|
|
323
|
+
const isAgent = id.startsWith('agent-')
|
|
324
|
+
|
|
325
|
+
for (const project of projects) {
|
|
326
|
+
const projectPath = join(projectsDir, project)
|
|
327
|
+
const sessionFile = join(projectPath, `${id}.jsonl`)
|
|
328
|
+
|
|
329
|
+
try {
|
|
330
|
+
const messages = await parseJsonl(sessionFile)
|
|
331
|
+
const fileStat = await stat(sessionFile)
|
|
332
|
+
const projectName = getProjectDisplayName(project)
|
|
333
|
+
let title = extractSessionTitle(messages)
|
|
334
|
+
|
|
335
|
+
// For agent sessions, try to find the description from parent session
|
|
336
|
+
if (isAgent) {
|
|
337
|
+
const agentId = id.replace('agent-', '')
|
|
338
|
+
const files = await readdir(projectPath)
|
|
339
|
+
for (const file of files) {
|
|
340
|
+
if (!file.startsWith('agent-') && file.endsWith('.jsonl')) {
|
|
341
|
+
try {
|
|
342
|
+
const parentMessages = await parseJsonl(join(projectPath, file))
|
|
343
|
+
const description = findAgentTitleFromParentMessages(parentMessages, agentId)
|
|
344
|
+
if (description) {
|
|
345
|
+
title = description
|
|
346
|
+
break
|
|
347
|
+
}
|
|
348
|
+
} catch {
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// If this is a main session (not agent), attach agent sessions
|
|
356
|
+
let agentSessions: Session[] | undefined
|
|
357
|
+
if (!isAgent) {
|
|
358
|
+
const agentDescriptions = collectAgentDescriptions(messages)
|
|
359
|
+
if (agentDescriptions.size > 0) {
|
|
360
|
+
agentSessions = await loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions)
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
session: {
|
|
366
|
+
id,
|
|
367
|
+
project: projectName,
|
|
368
|
+
timestamp: fileStat.mtime.toISOString(),
|
|
369
|
+
messages,
|
|
370
|
+
messageCount: messages.length,
|
|
371
|
+
title,
|
|
372
|
+
isAgent,
|
|
373
|
+
agentSessions
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
} catch {
|
|
377
|
+
continue
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return reply.code(404).send({ error: 'Session not found' })
|
|
382
|
+
} catch (error) {
|
|
383
|
+
console.error('Error reading session:', error)
|
|
384
|
+
return reply.code(500).send({ error: 'Internal server error' })
|
|
385
|
+
}
|
|
386
|
+
})
|
|
387
|
+
|
|
388
|
+
// WebSocket: Watch for file changes
|
|
389
|
+
server.register(async function (fastify) {
|
|
390
|
+
fastify.get('/ws', { websocket: true }, (socket) => {
|
|
391
|
+
const projectsDir = join(CLAUDE_DIR, 'projects')
|
|
392
|
+
|
|
393
|
+
const watcher = chokidar.watch(projectsDir, {
|
|
394
|
+
ignoreInitial: true,
|
|
395
|
+
persistent: true
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
watcher.on('add', (path) => {
|
|
399
|
+
socket.send(JSON.stringify({ type: 'file-added', path }))
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
watcher.on('change', (path) => {
|
|
403
|
+
socket.send(JSON.stringify({ type: 'file-changed', path }))
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
watcher.on('unlink', (path) => {
|
|
407
|
+
socket.send(JSON.stringify({ type: 'file-deleted', path }))
|
|
408
|
+
})
|
|
409
|
+
|
|
410
|
+
socket.on('close', () => {
|
|
411
|
+
watcher.close()
|
|
412
|
+
})
|
|
413
|
+
|
|
414
|
+
socket.on('error', (err: Error) => {
|
|
415
|
+
console.error('WebSocket error:', err)
|
|
416
|
+
})
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
// Start server
|
|
421
|
+
const start = async () => {
|
|
422
|
+
try {
|
|
423
|
+
const envPort = process.env.PORT ? Number(process.env.PORT) : undefined
|
|
424
|
+
const port = Number.isFinite(envPort) ? envPort : await getPort({ port: DEFAULT_PORT })
|
|
425
|
+
|
|
426
|
+
await server.listen({ port })
|
|
427
|
+
|
|
428
|
+
if (port !== DEFAULT_PORT) {
|
|
429
|
+
console.log(`Port ${DEFAULT_PORT} is in use, using port ${port} instead`)
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
console.log(`Server running on http://localhost:${port}`)
|
|
433
|
+
console.log(`Watching Claude directory: ${CLAUDE_DIR}`)
|
|
434
|
+
} catch (err) {
|
|
435
|
+
server.log.error(err)
|
|
436
|
+
process.exit(1)
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
start()
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2020",
|
|
4
|
+
"useDefineForClassFields": true,
|
|
5
|
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
6
|
+
"module": "ESNext",
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
|
|
9
|
+
/* Bundler mode */
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"resolveJsonModule": true,
|
|
13
|
+
"isolatedModules": true,
|
|
14
|
+
"noEmit": true,
|
|
15
|
+
"jsx": "react-jsx",
|
|
16
|
+
|
|
17
|
+
/* Linting */
|
|
18
|
+
"strict": true,
|
|
19
|
+
"noUnusedLocals": true,
|
|
20
|
+
"noUnusedParameters": true,
|
|
21
|
+
"noFallthroughCasesInSwitch": true,
|
|
22
|
+
|
|
23
|
+
/* Path aliases */
|
|
24
|
+
"baseUrl": ".",
|
|
25
|
+
"paths": {
|
|
26
|
+
"@/*": ["./src/*"]
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"include": ["src"],
|
|
30
|
+
"references": [{ "path": "./tsconfig.node.json" }]
|
|
31
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { defineConfig } from 'vite'
|
|
2
|
+
import react from '@vitejs/plugin-react'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
|
|
5
|
+
const serverPort = Number(process.env.VITE_API_PORT) || 3000
|
|
6
|
+
|
|
7
|
+
// https://vitejs.dev/config/
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
plugins: [react()],
|
|
10
|
+
resolve: {
|
|
11
|
+
alias: {
|
|
12
|
+
'@': path.resolve(__dirname, './src'),
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
server: {
|
|
16
|
+
port: 5173,
|
|
17
|
+
proxy: {
|
|
18
|
+
'/api': {
|
|
19
|
+
target: `http://localhost:${serverPort}`,
|
|
20
|
+
changeOrigin: true,
|
|
21
|
+
},
|
|
22
|
+
'/ws': {
|
|
23
|
+
target: `http://localhost:${serverPort}`,
|
|
24
|
+
ws: true,
|
|
25
|
+
changeOrigin: true,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
define: {
|
|
30
|
+
'import.meta.env.VITE_API_PORT': JSON.stringify(serverPort.toString()),
|
|
31
|
+
},
|
|
32
|
+
})
|