claude-session-viewer 0.1.0 → 0.1.2
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/README.md +45 -10
- package/bin/cli.js +44 -6
- package/bin/dev.js +18 -4
- package/dist/client/assets/index-D70FKSi-.js +40 -0
- package/dist/client/assets/index-DUAdsLyv.css +1 -0
- package/{index.html → dist/client/index.html} +2 -1
- package/dist/server/index.js +364 -0
- package/package.json +22 -10
- package/postcss.config.js +0 -6
- package/src/App.tsx +0 -174
- package/src/components/ProjectGroup.tsx +0 -132
- package/src/components/SessionDetail.tsx +0 -140
- package/src/components/SessionList.tsx +0 -45
- package/src/index.css +0 -55
- package/src/main.tsx +0 -10
- package/src/server/index.ts +0 -440
- package/tailwind.config.js +0 -11
- package/tsconfig.json +0 -31
- package/tsconfig.node.json +0 -10
- package/vite.config.ts +0 -32
|
@@ -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}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.right-0{right:0}.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}.ml-auto{margin-left:auto}.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-1{width:.25rem}.w-12{width:3rem}.w-4{width:1rem}.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))}.cursor-col-resize{cursor:col-resize}.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-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.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}.bg-transparent{background-color:transparent}.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))}.group:hover .group-hover\:bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / 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-D70FKSi-.js"></script>
|
|
9
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DUAdsLyv.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,36 +1,47 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-session-viewer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
|
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
|
},
|
|
16
27
|
"repository": {
|
|
17
|
-
|
|
18
|
-
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/hanyeol/claude-session-viewer.git"
|
|
19
30
|
},
|
|
20
31
|
"bugs": {
|
|
21
|
-
|
|
32
|
+
"url": "https://github.com/hanyeol/claude-session-viewer/issues"
|
|
22
33
|
},
|
|
23
34
|
"keywords": [
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
35
|
+
"claude",
|
|
36
|
+
"claude-code",
|
|
37
|
+
"session-viewer",
|
|
38
|
+
"cli"
|
|
28
39
|
],
|
|
29
40
|
"homepage": "https://github.com/hanyeol/claude-session-viewer",
|
|
30
41
|
"author": "Hanyeol Cho <hanyeol.cho@gmail.com>",
|
|
31
42
|
"license": "MIT",
|
|
32
43
|
"dependencies": {
|
|
33
|
-
"@fastify/
|
|
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",
|
|
@@ -40,6 +51,7 @@
|
|
|
40
51
|
"get-port": "^7.1.0",
|
|
41
52
|
"highlight.js": "^11.9.0",
|
|
42
53
|
"marked": "^11.1.1",
|
|
54
|
+
"open": "^11.0.0",
|
|
43
55
|
"react": "^18.2.0",
|
|
44
56
|
"react-dom": "^18.2.0",
|
|
45
57
|
"react-window": "^1.8.10",
|
package/postcss.config.js
DELETED
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
|