claude-session-viewer 0.1.0 → 0.1.1

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.
@@ -0,0 +1 @@
1
+ *,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.sticky{position:sticky}.top-0{top:0}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.ml-2{margin-left:.5rem}.ml-6{margin-left:1.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.flex{display:flex}.h-12{height:3rem}.h-4{height:1rem}.h-full{height:100%}.h-screen{height:100vh}.w-12{width:3rem}.w-4{width:1rem}.w-80{width:20rem}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-4xl{max-width:56rem}.flex-1{flex:1 1 0%}.rotate-90{--tw-rotate: 90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.flex-col{flex-direction:column}.items-start{align-items:flex-start}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-blue-500{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-transparent{border-color:transparent}.bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-gray-900\/50{background-color:#11182780}.bg-gray-900\/70{background-color:#111827b3}.bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.bg-purple-900\/50{background-color:#581c8780}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pl-14{padding-left:3.5rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.text-left{text-align:left}.text-center{text-align:center}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-green-200{--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}#root{width:100%;min-height:100vh}*{scrollbar-width:thin;scrollbar-color:#4B5563 #1F2937}*::-webkit-scrollbar{width:8px;height:8px}*::-webkit-scrollbar-track{background:#1f2937}*::-webkit-scrollbar-thumb{background-color:#4b5563;border-radius:4px}*::-webkit-scrollbar-thumb:hover{background-color:#6b7280}.hover\:bg-gray-700\/50:hover{background-color:#37415180}.hover\:bg-gray-800:hover{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}
@@ -5,9 +5,10 @@
5
5
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
7
  <title>Claude Session Viewer</title>
8
+ <script type="module" crossorigin src="/assets/index-BRGpp7Nq.js"></script>
9
+ <link rel="stylesheet" crossorigin href="/assets/index-bRG2avxz.css">
8
10
  </head>
9
11
  <body>
10
12
  <div id="root"></div>
11
- <script type="module" src="/src/main.tsx"></script>
12
13
  </body>
13
14
  </html>
@@ -0,0 +1,364 @@
1
+ import Fastify from 'fastify';
2
+ import fastifyStatic from '@fastify/static';
3
+ import websocket from '@fastify/websocket';
4
+ import { existsSync } from 'fs';
5
+ import { readdir, readFile, stat } from 'fs/promises';
6
+ import { homedir } from 'os';
7
+ import { dirname, join, resolve } from 'path';
8
+ import { fileURLToPath } from 'url';
9
+ import chokidar from 'chokidar';
10
+ import getPort from 'get-port';
11
+ const CLAUDE_DIR = join(homedir(), '.claude');
12
+ const SERVER_DIR = dirname(fileURLToPath(import.meta.url));
13
+ const CLIENT_DIST_DIR = resolve(SERVER_DIR, '../client');
14
+ const DEFAULT_PORT = 9090;
15
+ const server = Fastify({
16
+ logger: true
17
+ });
18
+ // Plugins
19
+ await server.register(websocket);
20
+ if (existsSync(CLIENT_DIST_DIR)) {
21
+ await server.register(fastifyStatic, {
22
+ root: CLIENT_DIST_DIR
23
+ });
24
+ server.setNotFoundHandler((request, reply) => {
25
+ const url = request.raw.url || '';
26
+ if (url.startsWith('/api') || url.startsWith('/ws')) {
27
+ reply.code(404).send({ error: 'Not found' });
28
+ return;
29
+ }
30
+ reply.sendFile('index.html');
31
+ });
32
+ }
33
+ // Helper: Parse JSONL file
34
+ async function parseJsonl(filePath) {
35
+ const content = await readFile(filePath, 'utf-8');
36
+ return content
37
+ .split('\n')
38
+ .filter(line => line.trim())
39
+ .map(line => JSON.parse(line));
40
+ }
41
+ // Helper: Clean text by removing tags
42
+ function cleanText(text) {
43
+ return text
44
+ .replace(/<ide_selection>[\s\S]*?<\/ide_selection>/g, ' ')
45
+ .replace(/<ide_opened_file>[\s\S]*?<\/ide_opened_file>/g, ' ')
46
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, ' ')
47
+ .replace(/\s+/g, ' ')
48
+ .trim();
49
+ }
50
+ function extractFirstText(content) {
51
+ if (Array.isArray(content)) {
52
+ for (const item of content) {
53
+ if (item.type === 'text' && item.text) {
54
+ const cleaned = cleanText(item.text);
55
+ if (cleaned) {
56
+ return cleaned;
57
+ }
58
+ }
59
+ }
60
+ return null;
61
+ }
62
+ if (typeof content === 'string') {
63
+ const cleaned = cleanText(content);
64
+ return cleaned || null;
65
+ }
66
+ return null;
67
+ }
68
+ // Helper: Extract title from session messages
69
+ function extractSessionTitle(messages) {
70
+ // First, try to find queue-operation / enqueue message
71
+ for (const msg of messages) {
72
+ if (msg.type === 'queue-operation' && msg.operation === 'enqueue' && msg.content) {
73
+ const firstText = extractFirstText(msg.content);
74
+ if (firstText) {
75
+ return firstText.substring(0, 100).trim();
76
+ }
77
+ }
78
+ }
79
+ // Fallback: Find first user message with actual text content
80
+ for (const msg of messages) {
81
+ if (msg.type === 'user' && msg.message?.content) {
82
+ const firstText = extractFirstText(msg.message.content);
83
+ if (firstText) {
84
+ return firstText.substring(0, 100).trim();
85
+ }
86
+ }
87
+ }
88
+ return 'Untitled Session';
89
+ }
90
+ function getProjectNameFromPath(projectPath) {
91
+ return projectPath.split('/').pop()?.replace(/-Users-hanyeol-Projects-/, '') || 'unknown';
92
+ }
93
+ function getProjectDisplayName(projectName) {
94
+ return projectName.replace(/-Users-hanyeol-Projects-/, '');
95
+ }
96
+ function collectAgentDescriptions(messages) {
97
+ const agentDescriptions = new Map();
98
+ const toolUseDescriptions = new Map();
99
+ const toolResultAgentIds = new Map();
100
+ for (const msg of messages) {
101
+ if (msg.type === 'assistant' && msg.message?.content && Array.isArray(msg.message.content)) {
102
+ for (const item of msg.message.content) {
103
+ if (item.type === 'tool_use' && item.name === 'Task' && item.input?.description) {
104
+ toolUseDescriptions.set(item.id, item.input.description);
105
+ }
106
+ }
107
+ }
108
+ const agentId = msg.agentId || msg.toolUseResult?.agentId;
109
+ if (agentId && msg.message?.content && Array.isArray(msg.message.content)) {
110
+ for (const item of msg.message.content) {
111
+ if (item.type === 'tool_result' && item.tool_use_id) {
112
+ toolResultAgentIds.set(item.tool_use_id, agentId);
113
+ }
114
+ }
115
+ }
116
+ }
117
+ for (const [toolUseId, description] of toolUseDescriptions.entries()) {
118
+ const agentId = toolResultAgentIds.get(toolUseId);
119
+ if (agentId) {
120
+ agentDescriptions.set(`agent-${agentId}`, description);
121
+ }
122
+ }
123
+ return agentDescriptions;
124
+ }
125
+ function attachAgentSessionsFromMap(session, agentDescriptions, agentSessionsMap) {
126
+ if (agentDescriptions.size === 0)
127
+ return;
128
+ session.agentSessions = [];
129
+ for (const [agentSessionId, description] of agentDescriptions) {
130
+ const agentSession = agentSessionsMap.get(agentSessionId);
131
+ if (agentSession) {
132
+ agentSession.title = description;
133
+ session.agentSessions.push(agentSession);
134
+ }
135
+ }
136
+ session.agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
137
+ }
138
+ async function loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions) {
139
+ const agentSessions = [];
140
+ for (const [agentSessionId, description] of agentDescriptions) {
141
+ const agentFile = join(projectPath, `${agentSessionId}.jsonl`);
142
+ try {
143
+ const agentMessages = await parseJsonl(agentFile);
144
+ const agentFileStat = await stat(agentFile);
145
+ agentSessions.push({
146
+ id: agentSessionId,
147
+ project: projectName,
148
+ timestamp: agentFileStat.mtime.toISOString(),
149
+ messages: agentMessages,
150
+ messageCount: agentMessages.length,
151
+ title: description,
152
+ isAgent: true
153
+ });
154
+ }
155
+ catch {
156
+ // Skip if agent file not found
157
+ }
158
+ }
159
+ agentSessions.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
160
+ return agentSessions;
161
+ }
162
+ function findAgentTitleFromParentMessages(messages, agentId) {
163
+ const agentDescriptions = collectAgentDescriptions(messages);
164
+ const description = agentDescriptions.get(`agent-${agentId}`);
165
+ return description || null;
166
+ }
167
+ // Helper: Get all sessions from a project directory
168
+ async function getProjectSessions(projectPath) {
169
+ const files = await readdir(projectPath);
170
+ const allSessions = [];
171
+ const agentSessionsMap = new Map();
172
+ // First pass: collect all sessions
173
+ for (const file of files) {
174
+ if (file.endsWith('.jsonl')) {
175
+ const filePath = join(projectPath, file);
176
+ const fileStat = await stat(filePath);
177
+ // Skip empty files
178
+ if (fileStat.size === 0)
179
+ continue;
180
+ try {
181
+ const messages = await parseJsonl(filePath);
182
+ // Filter: Skip sessions with only 1 message that is assistant-only
183
+ if (messages.length === 1 && messages[0].type === 'assistant') {
184
+ continue;
185
+ }
186
+ // Extract project name from path
187
+ const projectName = getProjectNameFromPath(projectPath);
188
+ // Extract session title
189
+ const title = extractSessionTitle(messages);
190
+ const sessionId = file.replace('.jsonl', '');
191
+ const isAgent = sessionId.startsWith('agent-');
192
+ const session = {
193
+ id: sessionId,
194
+ project: projectName,
195
+ timestamp: fileStat.mtime.toISOString(),
196
+ messages,
197
+ messageCount: messages.length,
198
+ title,
199
+ isAgent
200
+ };
201
+ if (isAgent) {
202
+ agentSessionsMap.set(sessionId, session);
203
+ }
204
+ else {
205
+ allSessions.push(session);
206
+ }
207
+ }
208
+ catch (error) {
209
+ console.error(`Error parsing ${file}:`, error);
210
+ }
211
+ }
212
+ }
213
+ // Second pass: attach agent sessions to their parent sessions
214
+ for (const session of allSessions) {
215
+ const agentDescriptions = collectAgentDescriptions(session.messages);
216
+ attachAgentSessionsFromMap(session, agentDescriptions, agentSessionsMap);
217
+ }
218
+ return allSessions;
219
+ }
220
+ // API: Get all sessions grouped by project
221
+ server.get('/api/sessions', async (request, reply) => {
222
+ try {
223
+ const projectsDir = join(CLAUDE_DIR, 'projects');
224
+ const projects = await readdir(projectsDir);
225
+ const projectGroups = [];
226
+ for (const project of projects) {
227
+ const projectPath = join(projectsDir, project);
228
+ const projectStat = await stat(projectPath);
229
+ if (projectStat.isDirectory()) {
230
+ const sessions = await getProjectSessions(projectPath);
231
+ if (sessions.length > 0) {
232
+ // Sort sessions by timestamp descending
233
+ sessions.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
234
+ const displayName = getProjectDisplayName(project);
235
+ projectGroups.push({
236
+ name: project,
237
+ displayName,
238
+ sessionCount: sessions.length,
239
+ lastActivity: sessions[0].timestamp, // Most recent session
240
+ sessions
241
+ });
242
+ }
243
+ }
244
+ }
245
+ // Sort project groups by last activity descending
246
+ projectGroups.sort((a, b) => new Date(b.lastActivity).getTime() - new Date(a.lastActivity).getTime());
247
+ return { projects: projectGroups };
248
+ }
249
+ catch (error) {
250
+ console.error('Error reading sessions:', error);
251
+ return { projects: [] };
252
+ }
253
+ });
254
+ // API: Get session by ID
255
+ server.get('/api/sessions/:id', async (request, reply) => {
256
+ try {
257
+ const { id } = request.params;
258
+ const projectsDir = join(CLAUDE_DIR, 'projects');
259
+ const projects = await readdir(projectsDir);
260
+ const isAgent = id.startsWith('agent-');
261
+ for (const project of projects) {
262
+ const projectPath = join(projectsDir, project);
263
+ const sessionFile = join(projectPath, `${id}.jsonl`);
264
+ try {
265
+ const messages = await parseJsonl(sessionFile);
266
+ const fileStat = await stat(sessionFile);
267
+ const projectName = getProjectDisplayName(project);
268
+ let title = extractSessionTitle(messages);
269
+ // For agent sessions, try to find the description from parent session
270
+ if (isAgent) {
271
+ const agentId = id.replace('agent-', '');
272
+ const files = await readdir(projectPath);
273
+ for (const file of files) {
274
+ if (!file.startsWith('agent-') && file.endsWith('.jsonl')) {
275
+ try {
276
+ const parentMessages = await parseJsonl(join(projectPath, file));
277
+ const description = findAgentTitleFromParentMessages(parentMessages, agentId);
278
+ if (description) {
279
+ title = description;
280
+ break;
281
+ }
282
+ }
283
+ catch {
284
+ continue;
285
+ }
286
+ }
287
+ }
288
+ }
289
+ // If this is a main session (not agent), attach agent sessions
290
+ let agentSessions;
291
+ if (!isAgent) {
292
+ const agentDescriptions = collectAgentDescriptions(messages);
293
+ if (agentDescriptions.size > 0) {
294
+ agentSessions = await loadAgentSessionsFromFiles(projectPath, projectName, agentDescriptions);
295
+ }
296
+ }
297
+ return {
298
+ session: {
299
+ id,
300
+ project: projectName,
301
+ timestamp: fileStat.mtime.toISOString(),
302
+ messages,
303
+ messageCount: messages.length,
304
+ title,
305
+ isAgent,
306
+ agentSessions
307
+ }
308
+ };
309
+ }
310
+ catch {
311
+ continue;
312
+ }
313
+ }
314
+ return reply.code(404).send({ error: 'Session not found' });
315
+ }
316
+ catch (error) {
317
+ console.error('Error reading session:', error);
318
+ return reply.code(500).send({ error: 'Internal server error' });
319
+ }
320
+ });
321
+ // WebSocket: Watch for file changes
322
+ server.register(async function (fastify) {
323
+ fastify.get('/ws', { websocket: true }, (socket) => {
324
+ const projectsDir = join(CLAUDE_DIR, 'projects');
325
+ const watcher = chokidar.watch(projectsDir, {
326
+ ignoreInitial: true,
327
+ persistent: true
328
+ });
329
+ watcher.on('add', (path) => {
330
+ socket.send(JSON.stringify({ type: 'file-added', path }));
331
+ });
332
+ watcher.on('change', (path) => {
333
+ socket.send(JSON.stringify({ type: 'file-changed', path }));
334
+ });
335
+ watcher.on('unlink', (path) => {
336
+ socket.send(JSON.stringify({ type: 'file-deleted', path }));
337
+ });
338
+ socket.on('close', () => {
339
+ watcher.close();
340
+ });
341
+ socket.on('error', (err) => {
342
+ console.error('WebSocket error:', err);
343
+ });
344
+ });
345
+ });
346
+ // Start server
347
+ const start = async () => {
348
+ try {
349
+ const envPort = process.env.PORT ? Number(process.env.PORT) : undefined;
350
+ const port = Number.isFinite(envPort) ? envPort : await getPort({ port: DEFAULT_PORT });
351
+ await server.listen({ port });
352
+ if (port !== DEFAULT_PORT) {
353
+ console.log(`Port ${DEFAULT_PORT} is in use, using port ${port} instead`);
354
+ }
355
+ const url = `http://localhost:${port}`;
356
+ console.log(`Server running on \x1b[36m${url}\x1b[0m`);
357
+ console.log(`Watching Claude directory: \x1b[36m${CLAUDE_DIR}\x1b[0m`);
358
+ }
359
+ catch (err) {
360
+ server.log.error(err);
361
+ process.exit(1);
362
+ }
363
+ };
364
+ start();
package/package.json CHANGED
@@ -1,15 +1,26 @@
1
1
  {
2
2
  "name": "claude-session-viewer",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "claude-session-viewer": "./bin/cli.js"
7
7
  },
8
+ "files": [
9
+ "bin/",
10
+ "dist/",
11
+ "package.json",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
8
15
  "scripts": {
9
16
  "dev": "node bin/dev.js",
10
17
  "dev:server": "tsx watch src/server/index.ts",
11
18
  "dev:client": "vite",
12
- "build": "tsc && vite build",
19
+ "build:server": "tsc -p tsconfig.server.json",
20
+ "build:client": "vite build --outDir dist/client",
21
+ "build": "npm run build:server && npm run build:client",
22
+ "start": "node dist/server/index.js",
23
+ "prepack": "npm run build",
13
24
  "preview": "vite preview",
14
25
  "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
15
26
  },
@@ -30,7 +41,7 @@
30
41
  "author": "Hanyeol Cho <hanyeol.cho@gmail.com>",
31
42
  "license": "MIT",
32
43
  "dependencies": {
33
- "@fastify/cors": "^9.0.1",
44
+ "@fastify/static": "^7.0.3",
34
45
  "@fastify/websocket": "^10.0.1",
35
46
  "@tanstack/react-query": "^5.17.19",
36
47
  "chokidar": "^3.5.3",
package/postcss.config.js DELETED
@@ -1,6 +0,0 @@
1
- export default {
2
- plugins: {
3
- tailwindcss: {},
4
- autoprefixer: {},
5
- },
6
- }
package/src/App.tsx DELETED
@@ -1,174 +0,0 @@
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