Skip to content

Commit b83399d

Browse files
committed
feat: Add search, bookmarks, live updates, and complete UI redesign
New Features: - πŸ” Add fuzzy search across all sessions with Fuse.js - ⭐ Add bookmarking system for favorite sessions - πŸ”„ Add live session updates with 5s polling when Claude is active - πŸ“€ Add export to Markdown functionality - ⌨️ Add keyboard navigation with arrow keys - πŸ“‹ Add visual feedback for copy actions (green checkmark animation) UI/UX Improvements: - 🎨 Switch from purple to Anthropic orange (#D97757) for Claude branding - πŸ“± Add mobile-responsive sidebar with hamburger menu - πŸ—οΈ Redesign with hierarchical folder structure and VSCode-style collapsing - ✨ Add loading shimmer animations and better empty states - πŸ› οΈ Create specialized tool renderers (code, file, search, diff, git) - πŸ’¬ Improve message separation with subtle backgrounds and borders Technical Changes: - Migrate state management to Zustand - Add React Query for data fetching and caching - Adopt Shadcn/ui component library - Implement custom design system with CSS variables - Add comprehensive TypeScript types - Remove Playwright tests and test infrastructure Performance: - Implement proper caching strategies - Add optimistic updates for UI interactions - Reduce unnecessary re-renders with React Query Dependencies: - Added: @tanstack/react-query, zustand, fuse.js, chokidar, @radix-ui/* - Removed: @playwright/test, playwright
1 parent 603576e commit b83399d

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

60 files changed

+5976
-1324
lines changed

β€ŽCONTRIBUTING.mdβ€Ž

Lines changed: 0 additions & 57 deletions
This file was deleted.

β€ŽREADME.mdβ€Ž

Lines changed: 16 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,17 @@ A modern web application for browsing and viewing Claude conversation session tr
55
## Features
66

77
- πŸ“ **File Tree Navigation**: Browse your Claude sessions organized by project directories
8-
- πŸ’¬ **Chat-Style Interface**: View conversations in a familiar chat format
8+
- πŸ’¬ **Chat-Style Interface**: View conversations in a familiar chat format
99
- πŸŒ— **Dark Mode Support**: Toggle between light and dark themes
10-
- πŸ” **Auto-Expand Navigation**: Direct links to sessions automatically expand the file tree
11-
- πŸ“‹ **Copy Session Paths**: Quickly copy JSONL file paths to clipboard
10+
- πŸ” **Search & Filter**: Search through all your sessions with fuzzy matching
11+
- ⭐ **Bookmarking**: Star your favorite sessions for quick access
12+
- πŸ”„ **Live Updates**: Automatically refreshes when Claude is actively running
13+
- πŸ“‹ **Copy Functionality**: Copy session IDs and tool outputs with visual feedback
1214
- πŸ“ **Markdown Rendering**: Full markdown support with syntax highlighting
13-
- πŸ› οΈ **Tool Visualization**: See tool calls and results in formatted blocks
15+
- πŸ› οΈ **Tool Visualization**: Specialized renderers for different tool types (bash, file operations, etc.)
16+
- πŸ“± **Responsive Design**: Works seamlessly on mobile and desktop
17+
- ⌨️ **Keyboard Navigation**: Navigate sessions with arrow keys
18+
- πŸ“€ **Export to Markdown**: Export entire conversations as markdown files
1419

1520
## Prerequisites
1621

@@ -22,7 +27,7 @@ A modern web application for browsing and viewing Claude conversation session tr
2227

2328
1. Clone the repository:
2429
```bash
25-
git clone https://github.com/phpmypython/claude-viewer.git
30+
git clone <repository-url>
2631
cd claude-viewer
2732
```
2833

@@ -67,17 +72,6 @@ When viewing a session, hover over it in the sidebar and click the copy icon to
6772

6873
## Development
6974

70-
### Running Tests
71-
```bash
72-
# Run all tests
73-
yarn test
74-
75-
# Run tests in watch mode
76-
yarn test:watch
77-
78-
# Run tests with UI
79-
yarn test:ui
80-
```
8175

8276
### Building for Production
8377
```bash
@@ -91,9 +85,11 @@ yarn start
9185
/api # API routes for reading sessions
9286
page.tsx # Main page component
9387
/components # React components
94-
Sidebar.tsx # File tree navigation
95-
ChatView.tsx # Conversation display
96-
/tests # Playwright E2E tests
88+
/session # Session-related components
89+
/tool-renderers # Tool call visualization components
90+
/ui # UI components (buttons, cards, etc.)
91+
/lib # Utility libraries and types
92+
/hooks # Custom React hooks
9793
```
9894

9995
## Configuration
@@ -115,4 +111,4 @@ Built with:
115111
- [React](https://reactjs.org/)
116112
- [Tailwind CSS](https://tailwindcss.com/)
117113
- [react-markdown](https://github.com/remarkjs/react-markdown)
118-
- [Playwright](https://playwright.dev/) for testing
114+
- [Radix UI](https://radix-ui.com/) for components

β€Žapp/api/sessions/route.tsβ€Ž

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { NextResponse } from 'next/server'
2-
import fs from 'fs/promises'
3-
import path from 'path'
4-
import os from 'os'
1+
import { NextResponse } from 'next/server';
2+
import { getSessionThreads } from '@/lib/session-parser';
3+
import path from 'path';
4+
import os from 'os';
5+
import fs from 'fs/promises';
56

67
/**
78
* GET /api/sessions
@@ -37,7 +38,7 @@ export async function GET() {
3738

3839
// Process each project directory to extract session information
3940
const projectData = await Promise.all(
40-
projects.map(async (projectName) => {
41+
projects.map(async (projectName: string) => {
4142
// Skip hidden files/directories
4243
if (projectName.startsWith('.')) return null
4344

@@ -48,11 +49,11 @@ export async function GET() {
4849

4950
// Get all JSONL session files in the project directory
5051
const sessions = await fs.readdir(projectPath)
51-
const jsonlSessions = sessions.filter(s => s.endsWith('.jsonl'))
52+
const jsonlSessions = sessions.filter((s: string) => s.endsWith('.jsonl'))
5253

5354
// Extract metadata and title from each session file
5455
const sessionData = await Promise.all(
55-
jsonlSessions.map(async (sessionFile) => {
56+
jsonlSessions.map(async (sessionFile: string) => {
5657
const sessionPath = path.join(projectPath, sessionFile)
5758
const sessionStats = await fs.stat(sessionPath)
5859

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
import chokidar from 'chokidar';
3+
import path from 'path';
4+
import os from 'os';
5+
import fs from 'fs/promises';
6+
7+
export async function GET(request: NextRequest) {
8+
const stream = new ReadableStream({
9+
async start(controller) {
10+
const encoder = new TextEncoder();
11+
const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
12+
13+
// Track file positions for incremental reads
14+
const filePositions = new Map<string, number>();
15+
16+
// Set up file watcher
17+
const watcher = chokidar.watch(`${claudeProjectsDir}/**/*.jsonl`, {
18+
persistent: true,
19+
ignoreInitial: true,
20+
awaitWriteFinish: {
21+
stabilityThreshold: 100,
22+
pollInterval: 50
23+
}
24+
});
25+
26+
// Send heartbeat every 30 seconds
27+
const heartbeatInterval = setInterval(() => {
28+
try {
29+
const heartbeat = JSON.stringify({
30+
type: 'heartbeat',
31+
timestamp: new Date().toISOString()
32+
});
33+
controller.enqueue(encoder.encode(`data: ${heartbeat}\n\n`));
34+
} catch (e) {
35+
// Client disconnected
36+
clearInterval(heartbeatInterval);
37+
watcher.close();
38+
}
39+
}, 30000);
40+
41+
// Handle file changes
42+
watcher.on('change', async (filepath) => {
43+
try {
44+
const lastPos = filePositions.get(filepath) || 0;
45+
const stats = await fs.stat(filepath);
46+
47+
if (stats.size > lastPos) {
48+
// Read only new content
49+
const buffer = Buffer.alloc(stats.size - lastPos);
50+
const fileHandle = await fs.open(filepath, 'r');
51+
await fileHandle.read(buffer, 0, buffer.length, lastPos);
52+
await fileHandle.close();
53+
54+
const newContent = buffer.toString('utf8');
55+
const newLines = newContent.split('\n').filter(line => line.trim());
56+
57+
filePositions.set(filepath, stats.size);
58+
59+
// Parse new messages
60+
const messages = [];
61+
for (const line of newLines) {
62+
try {
63+
messages.push(JSON.parse(line));
64+
} catch {
65+
// Skip invalid JSON
66+
}
67+
}
68+
69+
if (messages.length > 0) {
70+
const sessionId = path.basename(filepath, '.jsonl');
71+
const project = path.basename(path.dirname(filepath));
72+
73+
// Send multiple formats to handle different ID conventions
74+
const data = JSON.stringify({
75+
type: 'new_messages',
76+
sessionId,
77+
fullSessionId: `${project}/${sessionId}`,
78+
project,
79+
messages,
80+
messageCount: messages.length,
81+
timestamp: new Date().toISOString()
82+
});
83+
84+
console.log('[SSE] Sending new_messages event:', { sessionId, project, messageCount: messages.length });
85+
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
86+
}
87+
}
88+
} catch (error) {
89+
console.error('Error processing file change:', error);
90+
}
91+
});
92+
93+
// Handle new files
94+
watcher.on('add', async (filepath) => {
95+
try {
96+
const sessionId = path.basename(filepath, '.jsonl');
97+
const project = path.basename(path.dirname(filepath));
98+
99+
const data = JSON.stringify({
100+
type: 'new_session',
101+
sessionId,
102+
project,
103+
timestamp: new Date().toISOString()
104+
});
105+
106+
controller.enqueue(encoder.encode(`data: ${data}\n\n`));
107+
} catch (error) {
108+
console.error('Error processing new file:', error);
109+
}
110+
});
111+
112+
// Cleanup on disconnect
113+
request.signal.addEventListener('abort', () => {
114+
clearInterval(heartbeatInterval);
115+
watcher.close();
116+
});
117+
}
118+
});
119+
120+
return new NextResponse(stream, {
121+
headers: {
122+
'Content-Type': 'text/event-stream',
123+
'Cache-Control': 'no-cache',
124+
'Connection': 'keep-alive',
125+
},
126+
});
127+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { NextResponse } from 'next/server';
2+
import { getAllSessionFiles, buildSessionThread } from '@/lib/session-parser';
3+
import path from 'path';
4+
import os from 'os';
5+
6+
export async function GET(
7+
request: Request,
8+
{ params }: { params: { sessionId: string } }
9+
) {
10+
try {
11+
const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
12+
const allFiles = await getAllSessionFiles(claudeProjectsDir);
13+
const thread = await buildSessionThread(params.sessionId, allFiles);
14+
15+
return NextResponse.json(thread);
16+
} catch (error) {
17+
console.error('Error loading session thread:', error);
18+
return NextResponse.json(
19+
{ error: 'Failed to load session thread' },
20+
{ status: 500 }
21+
);
22+
}
23+
}

β€Žapp/api/sessions/v2/route.tsβ€Ž

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { NextResponse } from 'next/server';
2+
import { getSessionThreads } from '@/lib/session-parser';
3+
import path from 'path';
4+
import os from 'os';
5+
6+
export async function GET() {
7+
try {
8+
const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
9+
const threads = await getSessionThreads(claudeProjectsDir);
10+
11+
// Group threads by project
12+
const projectMap = new Map<string, any>();
13+
14+
for (const thread of threads) {
15+
// Get project name from first file
16+
const projectName = thread.files[0]?.project || 'unknown';
17+
18+
if (!projectMap.has(projectName)) {
19+
projectMap.set(projectName, {
20+
name: projectName,
21+
sessions: []
22+
});
23+
}
24+
25+
projectMap.get(projectName)!.sessions.push({
26+
id: thread.rootSessionId,
27+
title: thread.title || 'Untitled Session',
28+
messageCount: thread.messages.length,
29+
fileCount: thread.files.length,
30+
lastUpdated: thread.messages[thread.messages.length - 1]?.timestamp || new Date().toISOString(),
31+
firstMessage: thread.messages[0]?.timestamp || new Date().toISOString(),
32+
});
33+
}
34+
35+
const projects = Array.from(projectMap.values()).sort((a, b) =>
36+
a.name.localeCompare(b.name)
37+
);
38+
39+
return NextResponse.json({ projects });
40+
} catch (error) {
41+
console.error('Error loading sessions:', error);
42+
return NextResponse.json(
43+
{ error: 'Failed to load sessions' },
44+
{ status: 500 }
45+
);
46+
}
47+
}

β€Žapp/api/sessions/v3/route.tsβ€Ž

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { NextResponse } from 'next/server';
2+
import { getSessionThreads } from '@/lib/session-parser';
3+
import { buildProjectTree } from '@/lib/project-tree-builder';
4+
import path from 'path';
5+
import os from 'os';
6+
7+
export async function GET() {
8+
try {
9+
const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects');
10+
const threads = await getSessionThreads(claudeProjectsDir);
11+
const tree = buildProjectTree(threads);
12+
13+
return NextResponse.json({
14+
fileTree: tree.children,
15+
totalSessions: threads.length
16+
});
17+
} catch (error) {
18+
console.error('Error loading sessions:', error);
19+
return NextResponse.json(
20+
{ error: 'Failed to load sessions' },
21+
{ status: 500 }
22+
);
23+
}
24+
}

0 commit comments

Comments
Β (0)