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 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
+ }
@@ -0,0 +1,6 @@
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ }
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,10 @@
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App.tsx'
4
+ import './index.css'
5
+
6
+ ReactDOM.createRoot(document.getElementById('root')!).render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>,
10
+ )
@@ -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()
@@ -0,0 +1,11 @@
1
+ /** @type {import('tailwindcss').Config} */
2
+ export default {
3
+ content: [
4
+ "./index.html",
5
+ "./src/**/*.{js,ts,jsx,tsx}",
6
+ ],
7
+ theme: {
8
+ extend: {},
9
+ },
10
+ plugins: [],
11
+ }
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
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "compilerOptions": {
3
+ "composite": true,
4
+ "skipLibCheck": true,
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "allowSyntheticDefaultImports": true
8
+ },
9
+ "include": ["vite.config.ts"]
10
+ }
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
+ })