From f1ac484d49cdd929d75f80116e5da9dc33b76803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=B3=91=EC=9A=B1?= Date: Sun, 19 Oct 2025 23:46:47 +0900 Subject: [PATCH 1/6] feat: add web UI with Axum backend and professional dark theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented a complete web interface for RetroChat with the following features: **Backend (Rust/Axum)** - RESTful API endpoints for sessions, search, and health check - Session listing with pagination, filtering, and sorting - Session detail view with full message history - Full-text search across messages - Static file serving for frontend assets **Frontend (Vanilla JS/HTML/CSS)** - Professional dark theme inspired by Linear/GitHub/Vercel - Sessions browser with real-time filtering and sorting - Message search with highlighted results - Session detail modal with message timeline - Provider-specific color coding (Claude: orange, Gemini: blue, Cursor: light blue, Codex: white) - File path display for each session - Responsive design for mobile and desktop **Dependencies** - Added axum 0.7 for web framework - Added tower 0.4 for middleware - Added tower-http 0.5 for CORS and static file serving - Added mime_guess 2.0 for static file MIME types **CLI Integration** - New 'web' subcommand: retrochat web [--port PORT] [--host HOST] [--open] - Auto-open browser functionality with --open flag - Default port 3000, configurable via --port **Documentation** - Added project_path_extraction.md analyzing how to extract project paths from each provider ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.lock | 257 +++++++++++++- Cargo.toml | 4 + docs/project_path_extraction.md | 424 ++++++++++++++++++++++ src/cli/mod.rs | 18 + src/cli/web.rs | 27 ++ src/lib.rs | 1 + src/web/handlers/health.rs | 10 + src/web/handlers/mod.rs | 7 + src/web/handlers/search.rs | 47 +++ src/web/handlers/sessions.rs | 115 ++++++ src/web/mod.rs | 5 + src/web/routes/mod.rs | 23 ++ src/web/server.rs | 28 ++ src/web/static/app.js | 399 +++++++++++++++++++++ src/web/static/index.html | 109 ++++++ src/web/static/style.css | 608 ++++++++++++++++++++++++++++++++ 16 files changed, 2073 insertions(+), 9 deletions(-) create mode 100644 docs/project_path_extraction.md create mode 100644 src/cli/web.rs create mode 100644 src/web/handlers/health.rs create mode 100644 src/web/handlers/mod.rs create mode 100644 src/web/handlers/search.rs create mode 100644 src/web/handlers/sessions.rs create mode 100644 src/web/mod.rs create mode 100644 src/web/routes/mod.rs create mode 100644 src/web/server.rs create mode 100644 src/web/static/app.js create mode 100644 src/web/static/index.html create mode 100644 src/web/static/style.css diff --git a/Cargo.lock b/Cargo.lock index 8326313..a563d51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -136,6 +136,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "atty" version = "0.2.14" @@ -153,6 +159,61 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.7.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "backoff" version = "0.4.0" @@ -915,7 +976,7 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", "indexmap", "slab", "tokio", @@ -1037,6 +1098,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -1044,10 +1116,39 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1071,8 +1172,8 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -1084,6 +1185,27 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + [[package]] name = "hyper-tls" version = "0.5.0" @@ -1091,12 +1213,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" dependencies = [ "bytes", - "hyper", + "hyper 0.14.32", "native-tls", "tokio", "tokio-native-tls", ] +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.7.0", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -1454,6 +1592,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + [[package]] name = "md-5" version = "0.10.6" @@ -1476,6 +1620,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2091,9 +2245,9 @@ dependencies = [ "futures-core", "futures-util", "h2", - "http", - "http-body", - "hyper", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", "hyper-tls", "ipnet", "js-sys", @@ -2107,7 +2261,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", + "sync_wrapper 0.1.2", "system-configuration", "tokio", "tokio-native-tls", @@ -2126,6 +2280,7 @@ dependencies = [ "anyhow", "async-trait", "atty", + "axum", "backoff", "bytes", "chrono", @@ -2137,6 +2292,7 @@ dependencies = [ "futures", "hex", "indicatif", + "mime_guess", "mockall", "notify", "num_cpus", @@ -2152,6 +2308,8 @@ dependencies = [ "thiserror", "tokio", "tokio-util", + "tower 0.4.13", + "tower-http", "tracing", "tracing-appender", "tracing-subscriber", @@ -2367,6 +2525,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2807,6 +2976,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -3018,6 +3193,64 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.9.4", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -3106,6 +3339,12 @@ version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 75b0c1c..5ace0fd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,10 @@ notify = "8.1.0" similar = "2.7" num_cpus = "1.16" indicatif = "0.17" +axum = "0.7" +tower = "0.4" +tower-http = { version = "0.5", features = ["cors", "fs", "trace"] } +mime_guess = "2.0" [features] default = ["reqwest"] diff --git a/docs/project_path_extraction.md b/docs/project_path_extraction.md new file mode 100644 index 0000000..48ef362 --- /dev/null +++ b/docs/project_path_extraction.md @@ -0,0 +1,424 @@ +# Project Path Extraction Analysis + +**์ž‘์„ฑ์ผ**: 2025-10-19 +**๋ชฉ์ **: ๊ฐ Provider์—์„œ ์„ธ์…˜์ด ์‹คํ–‰๋œ ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ(working directory)๋ฅผ ์ถ”์ถœํ•˜๋Š” ๋ฐฉ๋ฒ• ์กฐ์‚ฌ + +--- + +## ๊ฐœ์š” + +ํ˜„์žฌ RetroChat๋Š” `ChatSession` ๋ชจ๋ธ์— `project_name` ํ•„๋“œ๋งŒ ์ €์žฅํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์‚ฌ์šฉ์ž๊ฐ€ ์‹ค์ œ ์„ธ์…˜์ด ์‹คํ–‰๋œ **์ „์ฒด ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ**๋ฅผ ํ™•์ธํ•˜๊ณ  ์‹ถ์–ดํ•˜๋ฏ€๋กœ, ๊ฐ provider์˜ ๋ฐ์ดํ„ฐ์—์„œ ์ด ์ •๋ณด๋ฅผ ์–ป์„ ์ˆ˜ ์žˆ๋Š”์ง€ ์กฐ์‚ฌํ–ˆ์Šต๋‹ˆ๋‹ค. + +## ํ˜„์žฌ ์ƒํƒœ + +### ChatSession ๋ชจ๋ธ (`src/models/chat_session.rs`) +```rust +pub struct ChatSession { + pub id: Uuid, + pub provider: Provider, + pub project_name: Option, // ํ”„๋กœ์ ํŠธ ์ด๋ฆ„๋งŒ ์ €์žฅ + pub file_path: String, // chat history ํŒŒ์ผ ๊ฒฝ๋กœ + // ... +} +``` + +**๋ฌธ์ œ์ **: +- `project_name`: ํ”„๋กœ์ ํŠธ ์ด๋ฆ„๋งŒ (์˜ˆ: "retrochat") +- `file_path`: chat history ํŒŒ์ผ์˜ ์ €์žฅ ๊ฒฝ๋กœ (์˜ˆ: `/Users/lullu/.claude/projects/-Users-lullu-study-retrochat/abc.jsonl`) +- **์‹ค์ œ ์„ธ์…˜ ์‹คํ–‰ ๊ฒฝ๋กœ**: ์ €์žฅ ์•ˆ ๋จ (์˜ˆ: `/Users/lullu/study/retrochat` โŒ) + +--- + +## Provider๋ณ„ ๋ถ„์„ + +### 1. Claude Code + +#### ํŒŒ์ผ ๊ตฌ์กฐ +``` +~/.claude/projects/ + โ””โ”€โ”€ -Users-lullu-study-retrochat/ + โ””โ”€โ”€ 61ac7e7d-8fdd-46f9-8d8e-4793aeeac69b.jsonl +``` + +#### ๋ฐ์ดํ„ฐ ํฌ๋งท +```json +{ + "type": "conversation", + "sessionId": "550e8400-e29b-41d4-a716-446655440000", + "timestamp": "2024-01-01T10:00:00Z", + "message": { + "role": "user", + "content": "Hello" + } +} +``` + +#### Project Path ์ถ”์ถœ ๋ฐฉ๋ฒ• + +**โœ… ๊ฐ€๋Šฅ - ํŒŒ์ผ๋ช… ๋””๋ ‰ํ† ๋ฆฌ ํŒจํ„ด ๋””์ฝ”๋”ฉ** + +Claude Code๋Š” ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ๋ฅผ ๋””๋ ‰ํ† ๋ฆฌ๋ช…์— ์ธ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค: +- ์ธ์ฝ”๋”ฉ ํŒจํ„ด: `-Users-lullu-study-retrochat` +- ์›๋ณธ ๊ฒฝ๋กœ: `/Users/lullu/study/retrochat` + +**ํ˜„์žฌ ๊ตฌํ˜„**: +- `ProjectInference` (`src/parsers/project_inference.rs`)๊ฐ€ ์ด๋ฏธ ์ด ํŒจํ„ด์„ ํŒŒ์‹ฑํ•˜์ง€๋งŒ, **ํ”„๋กœ์ ํŠธ ์ด๋ฆ„๋งŒ** ์ถ”์ถœ +- Line 16-45: `infer_project_name()` - ๋งˆ์ง€๋ง‰ ๋””๋ ‰ํ† ๋ฆฌ ์ด๋ฆ„๋งŒ ๋ฐ˜ํ™˜ + +**ํ•„์š”ํ•œ ์ž‘์—…**: +```rust +impl ProjectInference { + // ์ƒˆ๋กœ์šด ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ + pub fn infer_project_path(&self) -> Option { + // -Users-lullu-study-retrochat โ†’ /Users/lullu/study/retrochat + // resolve_original_path()๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ „์ฒด ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ + } +} +``` + +**๊ตฌํ˜„ ๋‚œ์ด๋„**: โญ (์‰ฌ์›€) +- ๊ธฐ์กด `resolve_original_path()` ๋กœ์ง ํ™œ์šฉ ๊ฐ€๋Šฅ +- ์ด๋ฏธ ํŒŒ์ผ์‹œ์Šคํ…œ ๊ฒ€์ฆ ๋กœ์ง ์กด์žฌ + +--- + +### 2. Cursor Agent + +#### ํŒŒ์ผ ๊ตฌ์กฐ +``` +/Users/lullu/study/retrochat/.cursor/ + โ””โ”€โ”€ chats/ + โ””โ”€โ”€ 53460df9022de1a66445a5b78b067dd9/ (hash) + โ””โ”€โ”€ 557abc41-6f00-41e7-bf7b-696c80d4ee94/ (UUID) + โ””โ”€โ”€ store.db +``` + +#### ๋ฐ์ดํ„ฐ ํฌ๋งท (SQLite) +```sql +-- meta ํ…Œ์ด๋ธ” +CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT); + +-- metadata JSON (hex-encoded) +{ + "agentId": "557abc41-6f00-41e7-bf7b-696c80d4ee94", + "name": "Chat Session 1", + "mode": "default", + "createdAt": 1758872189097, + "lastUsedModel": "claude-3-5-sonnet" +} +``` + +**โš ๏ธ ์ฃผ์˜**: Cursor metadata์—๋Š” ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ ์ •๋ณด๊ฐ€ **ํฌํ•จ๋˜์ง€ ์•Š์Œ** + +#### Project Path ์ถ”์ถœ ๋ฐฉ๋ฒ• + +**โœ… ๊ฐ€๋Šฅ - ํŒŒ์ผ ๊ฒฝ๋กœ ์—ญ์ถ”์ ** + +๊ตฌ์กฐ: `{project_path}/.cursor/chats/{hash}/{uuid}/store.db` + +**ํ˜„์žฌ ๊ตฌํ˜„**: +```rust +// src/parsers/cursor_agent.rs:370-417 +fn infer_project_name(&self, metadata: &CursorChatMetadata) -> Option { + let path = PathBuf::from(&self.db_path); + + // store.db โ†’ uuid_dir โ†’ hash_dir โ†’ chats_dir โ†’ .cursor โ†’ project_dir + if let Some(uuid_dir) = path.parent() { + if let Some(hash_dir) = uuid_dir.parent() { + if let Some(chats_dir) = hash_dir.parent() { + if let Some(cursor_dir) = chats_dir.parent() { + if let Some(project_dir) = cursor_dir.parent() { + // โœ… ์ด๋ฏธ project_dir๋ฅผ ์ฐพ๊ณ  ์žˆ์Œ! + return Some(project_dir.to_string_lossy().to_string()); + } + } + } + } + } + None +} +``` + +**ํ•„์š”ํ•œ ์ž‘์—…**: +- ํ˜„์žฌ `project_dir.file_name()`๋งŒ ๋ฐ˜ํ™˜ (์ด๋ฆ„๋งŒ) +- **์ „์ฒด ๊ฒฝ๋กœ**๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ์ˆ˜์ • + +**๊ตฌํ˜„ ๋‚œ์ด๋„**: โญ (๋งค์šฐ ์‰ฌ์›€) +- ์ด๋ฏธ `project_dir`๋ฅผ ์ฐพ๋Š” ๋กœ์ง ์กด์žฌ +- `file_name()` โ†’ `to_string_lossy().to_string()` ๋ณ€๊ฒฝ๋งŒ ํ•„์š” + +--- + +### 3. Gemini CLI + +#### ํŒŒ์ผ ๊ตฌ์กฐ +``` +(์‚ฌ์šฉ์ž ์ง€์ • ์œ„์น˜์˜ JSON ํŒŒ์ผ) +์˜ˆ: ~/gemini-exports/session-1234567890-abc.json +``` + +#### ๋ฐ์ดํ„ฐ ํฌ๋งท (์—ฌ๋Ÿฌ ๋ฒ„์ „) + +**Format 1 - Session with metadata**: +```json +{ + "sessionId": "session-1234567890-abc", + "projectHash": "abc123", // โš ๏ธ hash๋งŒ ์žˆ์Œ + "startTime": "2024-01-01T10:00:00Z", + "lastUpdated": "2024-01-01T11:00:00Z", + "messages": [...] +} +``` + +**Format 2 - Old export format**: +```json +{ + "conversations": [{ + "conversation_id": "test-123", + "title": "Test Chat", // โš ๏ธ title๋งŒ ์žˆ์Œ + "conversation": [...] + }] +} +``` + +**Format 3 - Array format**: +```json +[ + { + "sessionId": "session-123", + "messageId": 1, + "type": "user", + "message": "hello", + "timestamp": "2024-01-01T10:00:00Z" + } +] +``` + +#### Project Path ์ถ”์ถœ ๋ฐฉ๋ฒ• + +**โŒ ๋ถˆ๊ฐ€๋Šฅ - ๋ฐ์ดํ„ฐ์— ๊ฒฝ๋กœ ์ •๋ณด ์—†์Œ** + +Gemini CLI ๋ฐ์ดํ„ฐ์—๋Š” ํ”„๋กœ์ ํŠธ ์‹คํ–‰ ๊ฒฝ๋กœ ์ •๋ณด๊ฐ€ ์ „ํ˜€ ์—†์Šต๋‹ˆ๋‹ค: +- `projectHash`: ํ•ด์‹œ๊ฐ’๋งŒ (์‹ค์ œ ๊ฒฝ๋กœ ์•„๋‹˜) +- `title`: ์‚ฌ์šฉ์ž๊ฐ€ ์ง€์ •ํ•œ ์ œ๋ชฉ +- ์„ธ์…˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์— `cwd`, `git`, `path` ๋“ฑ์˜ ํ•„๋“œ ์—†์Œ + +**ํ˜„์žฌ ๊ตฌํ˜„**: +```rust +// src/parsers/gemini_cli.rs:241-248, 304-313 +// project_hash๋ฅผ ํ”„๋กœ์ ํŠธ ์ด๋ฆ„์œผ๋กœ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ +// ProjectInference๋กœ ํŒŒ์ผ ๊ฒฝ๋กœ์—์„œ ์ถ”๋ก  +``` + +**๋Œ€์•ˆ**: +1. **ํŒŒ์ผ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ์ถ”๋ก **: `ProjectInference`๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ JSON ํŒŒ์ผ์ด ์œ„์น˜ํ•œ ๋””๋ ‰ํ† ๋ฆฌ ๊ฒฝ๋กœ ํ™œ์šฉ + - ์˜ˆ: `/Users/lullu/projects/myapp/gemini-data/session.json` โ†’ `/Users/lullu/projects/myapp` + - โš ๏ธ ์ •ํ™•๋„ ๋‚ฎ์Œ (export ์œ„์น˜ โ‰  ์‹คํ–‰ ์œ„์น˜) + +2. **์ˆ˜๋™ ์„ค์ •**: ์‚ฌ์šฉ์ž๊ฐ€ import ์‹œ ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ๋ฅผ ์ง์ ‘ ์ง€์ • + - CLI ์˜ต์…˜: `retrochat import --path file.json --project-path /Users/lullu/projects/myapp` + +**๊ตฌํ˜„ ๋‚œ์ด๋„**: โญโญโญ (์–ด๋ ค์›€) +- ๋ฐ์ดํ„ฐ์— ์ •๋ณด๊ฐ€ ์—†์–ด ์ถ”๋ก ๋งŒ ๊ฐ€๋Šฅ +- ์‹ ๋ขฐ๋„๊ฐ€ ๋‚ฎ์Œ + +--- + +### 4. Codex + +#### ํŒŒ์ผ ๊ตฌ์กฐ +``` +(์‚ฌ์šฉ์ž ์ง€์ • ์œ„์น˜์˜ JSONL ํŒŒ์ผ) +์˜ˆ: ~/codex-sessions/session-abc.jsonl +``` + +#### ๋ฐ์ดํ„ฐ ํฌ๋งท + +**New Format (with cwd)**: +```jsonl +{"timestamp":"2025-10-12T14:10:16.717Z","type":"session_meta","payload":{"id":"0199d8c1-ffeb-7b21-9ebe-f35fbbcf7a59","timestamp":"2025-10-12T14:10:16.683Z","cwd":"/Users/test/project","git":{"commit_hash":"abc123","branch":"main","repository_url":"git@github.com:user/test-project.git"}}} +{"timestamp":"2025-10-12T17:53:40.556Z","type":"event_msg","payload":{"type":"user_message","message":"hello"}} +``` + +#### Project Path ์ถ”์ถœ ๋ฐฉ๋ฒ• + +**โœ… ๊ฐ€๋Šฅ - ์ง์ ‘ ์ œ๊ณต๋จ!** + +Codex๋Š” ์„ธ์…˜ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์— **์‹ค์ œ ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ(cwd)๋ฅผ ์ง์ ‘ ์ œ๊ณต**ํ•ฉ๋‹ˆ๋‹ค! + +**ํ˜„์žฌ ๊ตฌํ˜„**: +```rust +// src/parsers/codex.rs:54 +pub struct SessionMetaPayload { + pub id: String, + pub timestamp: String, + pub cwd: Option, // โœ… ์ „์ฒด ๊ฒฝ๋กœ! + pub instructions: Option, + pub git: Option, // โœ… Git ์ •๋ณด๋„ ํฌํ•จ +} + +// src/parsers/codex.rs:404-412 +fn infer_project_name_by_cwd(&self, meta: &SessionMetaPayload) -> Option { + meta.cwd.as_ref().and_then(|cwd_path| { + Path::new(cwd_path) + .file_name() // โš ๏ธ ์ด๋ฆ„๋งŒ ์ถ”์ถœ ์ค‘ + .and_then(|name| name.to_str()) + .map(|s| s.to_string()) + }) +} +``` + +**ํ•„์š”ํ•œ ์ž‘์—…**: +- `meta.cwd`๋ฅผ ๊ทธ๋Œ€๋กœ ๋ฐ˜ํ™˜ (์ด๋ฏธ ์ „์ฒด ๊ฒฝ๋กœ) +- Git repository URL๋„ ์ €์žฅ ๊ณ ๋ ค + +**๊ตฌํ˜„ ๋‚œ์ด๋„**: โญ (๋งค์šฐ ์‰ฌ์›€) +- ์ด๋ฏธ ๋ฐ์ดํ„ฐ๊ฐ€ ์ค€๋น„๋จ +- ๋‹จ์ˆœํžˆ ๋ฐ˜ํ™˜๋งŒ ํ•˜๋ฉด ๋จ + +**์ถ”๊ฐ€ ์ •๋ณด**: +```rust +pub struct CodexGitInfo { + pub commit_hash: Option, + pub branch: Option, + pub repository_url: Option, // ์˜ˆ: "git@github.com:user/project.git" +} +``` + +--- + +## ๊ตฌํ˜„ ์ œ์•ˆ + +### Phase 1: ๋ชจ๋ธ ํ™•์žฅ + +**`ChatSession` ๋ชจ๋ธ์— `project_path` ํ•„๋“œ ์ถ”๊ฐ€** + +```rust +// src/models/chat_session.rs +pub struct ChatSession { + pub id: Uuid, + pub provider: Provider, + pub project_name: Option, // ๊ธฐ์กด: ํ”„๋กœ์ ํŠธ ์ด๋ฆ„ + pub project_path: Option, // ์‹ ๊ทœ: ์ „์ฒด ๊ฒฝ๋กœ + pub file_path: String, + // ... +} +``` + +**๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜**: +```sql +-- migrations/YYYYMMDD_add_project_path.sql +ALTER TABLE chat_sessions +ADD COLUMN project_path TEXT; +``` + +### Phase 2: Provider๋ณ„ ๊ตฌํ˜„ + +#### ์šฐ์„ ์ˆœ์œ„ 1 - Codex (๊ฐ€์žฅ ์‰ฌ์›€, ์ •ํ™•๋„ ๋†’์Œ) +```rust +// src/parsers/codex.rs +fn extract_project_path(&self, meta: &SessionMetaPayload) -> Option { + meta.cwd.clone() // ์ง์ ‘ ์ œ๊ณต๋จ! +} +``` + +#### ์šฐ์„ ์ˆœ์œ„ 2 - Cursor (์‰ฌ์›€, ์ •ํ™•๋„ ๋†’์Œ) +```rust +// src/parsers/cursor_agent.rs +fn infer_project_path(&self) -> Option { + let path = PathBuf::from(&self.db_path); + // 5๋‹จ๊ณ„ parent()๋กœ project_dir ์ฐพ๊ธฐ + path.parent()?.parent()?.parent()?.parent()?.parent() + .map(|p| p.to_string_lossy().to_string()) +} +``` + +#### ์šฐ์„ ์ˆœ์œ„ 3 - Claude Code (์ค‘๊ฐ„, ์ •ํ™•๋„ ๋†’์Œ) +```rust +// src/parsers/project_inference.rs +impl ProjectInference { + pub fn infer_project_path(&self) -> Option { + // -Users-lullu-study-retrochat โ†’ /Users/lullu/study/retrochat + // resolve_original_path() ์žฌ์‚ฌ์šฉ + let path = Path::new(&self.file_path); + if let Some(parent_dir) = path.parent() { + let parent_name = parent_dir.file_name()?.to_str()?; + if parent_name.starts_with('-') { + return self.resolve_original_path(parent_name); + } + } + None + } +} +``` + +#### ์šฐ์„ ์ˆœ์œ„ 4 - Gemini (์–ด๋ ค์›€, ์ •ํ™•๋„ ๋‚ฎ์Œ) +```rust +// src/parsers/gemini_cli.rs +// Option 1: ํŒŒ์ผ ๊ฒฝ๋กœ ๊ธฐ๋ฐ˜ ์ถ”๋ก  (๋ถ€์ •ํ™•) +// Option 2: ์‚ฌ์šฉ์ž ์ˆ˜๋™ ์ž…๋ ฅ (CLI ์˜ต์…˜ ์ถ”๊ฐ€ ํ•„์š”) +``` + +### Phase 3: UI ์—…๋ฐ์ดํŠธ + +**Web UI์—์„œ ํ‘œ์‹œ**: +```javascript +// src/web/static/app.js +${session.project_path ? ` +
+ Project Path + + ${escapeHtml(session.project_path)} + +
+` : ''} +``` + +**TUI์—์„œ ํ‘œ์‹œ**: +```rust +// src/tui/views/session_detail.rs +if let Some(path) = &session.project_path { + Paragraph::new(format!("Path: {}", path)) + .style(Style::default().fg(Color::Cyan)) +} +``` + +--- + +## ์š”์•ฝ ๋น„๊ตํ‘œ + +| Provider | ๋ฐ์ดํ„ฐ ๊ฐ€์šฉ์„ฑ | ์ถ”์ถœ ๋ฐฉ๋ฒ• | ์ •ํ™•๋„ | ๊ตฌํ˜„ ๋‚œ์ด๋„ | ๋น„๊ณ  | +|----------|-------------|----------|--------|-----------|------| +| **Codex** | โœ… ์ง์ ‘ ์ œ๊ณต | `meta.cwd` ํ•„๋“œ | 100% | โญ ๋งค์šฐ ์‰ฌ์›€ | `cwd` ํ•„๋“œ์— ์ „์ฒด ๊ฒฝ๋กœ ํฌํ•จ | +| **Cursor** | โœ… ํŒŒ์ผ ๊ตฌ์กฐ | ๊ฒฝ๋กœ ์—ญ์ถ”์  (5x parent) | 100% | โญ ์‰ฌ์›€ | `.cursor/chats/...` ๊ตฌ์กฐ ํ™œ์šฉ | +| **Claude Code** | โœ… ํŒŒ์ผ๋ช… ํŒจํ„ด | ๋””๋ ‰ํ† ๋ฆฌ๋ช… ๋””์ฝ”๋”ฉ | 95% | โญโญ ์ค‘๊ฐ„ | `-Users-...` ํŒจํ„ด ํŒŒ์‹ฑ | +| **Gemini CLI** | โŒ ์—†์Œ | ํŒŒ์ผ ์œ„์น˜ ์ถ”๋ก  OR ์ˆ˜๋™ ์ž…๋ ฅ | 30% | โญโญโญ ์–ด๋ ค์›€ | ๋ฐ์ดํ„ฐ์— ์ •๋ณด ์—†์Œ | + +--- + +## ๋‹ค์Œ ๋‹จ๊ณ„ + +1. โœ… **์กฐ์‚ฌ ์™„๋ฃŒ** (์ด ๋ฌธ์„œ) +2. โฌœ **๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์ž‘์„ฑ**: `project_path` ์ปฌ๋Ÿผ ์ถ”๊ฐ€ +3. โฌœ **๋ชจ๋ธ ์—…๋ฐ์ดํŠธ**: `ChatSession` ๊ตฌ์กฐ์ฒด ์ˆ˜์ • +4. โฌœ **Parser ๊ตฌํ˜„**: ๊ฐ provider๋ณ„ ๊ฒฝ๋กœ ์ถ”์ถœ ๋กœ์ง + - Codex (์šฐ์„ ) + - Cursor + - Claude Code + - Gemini (Optional) +5. โฌœ **Repository/Service ์ˆ˜์ •**: ๋ฐ์ดํ„ฐ ์ €์žฅ/์กฐํšŒ ๋กœ์ง +6. โฌœ **UI ์—…๋ฐ์ดํŠธ**: Web UI, TUI์— ๊ฒฝ๋กœ ํ‘œ์‹œ +7. โฌœ **ํ…Œ์ŠคํŠธ ์ž‘์„ฑ**: ๊ฐ provider๋ณ„ ๊ฒฝ๋กœ ์ถ”์ถœ ํ…Œ์ŠคํŠธ + +--- + +## ์ฐธ๊ณ  ํŒŒ์ผ + +- `src/models/chat_session.rs`: ChatSession ๋ชจ๋ธ +- `src/parsers/claude_code.rs`: Claude Code ํŒŒ์„œ +- `src/parsers/cursor_agent.rs`: Cursor ํŒŒ์„œ +- `src/parsers/gemini_cli.rs`: Gemini CLI ํŒŒ์„œ +- `src/parsers/codex.rs`: Codex ํŒŒ์„œ +- `src/parsers/project_inference.rs`: ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ ์ถ”๋ก  ์œ ํ‹ธ +- `tests/contract/test_cursor_integration.rs`: Cursor ํ…Œ์ŠคํŠธ ์ƒ˜ํ”Œ + diff --git a/src/cli/mod.rs b/src/cli/mod.rs index f7f1d64..85dfa3d 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ pub mod query; pub mod retrospect; pub mod tui; pub mod watch; +pub mod web; use clap::{Parser, Subcommand}; use std::sync::Arc; @@ -93,6 +94,20 @@ pub enum Commands { #[command(subcommand)] command: RetrospectCommands, }, + /// Launch Web UI server + Web { + /// Port to run the web server on + #[arg(short, long, default_value = "3000")] + port: u16, + + /// Host to bind to + #[arg(long, default_value = "127.0.0.1")] + host: String, + + /// Open browser automatically + #[arg(short, long)] + open: bool, + }, } #[derive(Subcommand)] @@ -223,6 +238,9 @@ impl Cli { retrospect::handle_cancel_command(request_id, all).await } }, + Commands::Web { port, host, open } => { + web::handle_web_command(host, port, open).await + } } }) } diff --git a/src/cli/web.rs b/src/cli/web.rs new file mode 100644 index 0000000..d5edaae --- /dev/null +++ b/src/cli/web.rs @@ -0,0 +1,27 @@ +use anyhow::Result; + +pub async fn handle_web_command(host: String, port: u16, open: bool) -> Result<()> { + let url = format!("http://{host}:{port}"); + + // Open browser if requested + if open { + println!("๐Ÿš€ Opening browser at {url}"); + // Try to open browser, but don't fail if it doesn't work + #[cfg(not(target_os = "windows"))] + { + let _ = std::process::Command::new("open") + .arg(&url) + .spawn() + .or_else(|_| std::process::Command::new("xdg-open").arg(&url).spawn()); + } + #[cfg(target_os = "windows")] + { + let _ = std::process::Command::new("cmd") + .args(["/C", "start", &url]) + .spawn(); + } + } + + // Start the web server + crate::web::run_server(&host, port).await +} diff --git a/src/lib.rs b/src/lib.rs index aee0f2d..293005d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod parsers; pub mod services; pub mod tools; pub mod tui; +pub mod web; pub mod env; pub mod error; diff --git a/src/web/handlers/health.rs b/src/web/handlers/health.rs new file mode 100644 index 0000000..7988132 --- /dev/null +++ b/src/web/handlers/health.rs @@ -0,0 +1,10 @@ +use axum::Json; +use serde_json::{json, Value}; + +pub async fn health_check() -> Json { + Json(json!({ + "status": "ok", + "service": "retrochat-web", + "version": env!("CARGO_PKG_VERSION") + })) +} diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs new file mode 100644 index 0000000..24bd8ba --- /dev/null +++ b/src/web/handlers/mod.rs @@ -0,0 +1,7 @@ +pub mod health; +pub mod search; +pub mod sessions; + +pub use health::health_check; +pub use search::search_messages; +pub use sessions::{get_session_detail, list_sessions}; diff --git a/src/web/handlers/search.rs b/src/web/handlers/search.rs new file mode 100644 index 0000000..f63966c --- /dev/null +++ b/src/web/handlers/search.rs @@ -0,0 +1,47 @@ +use axum::{extract::Query, Json}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::database::DatabaseManager; +use crate::services::{QueryService, SearchRequest}; + +use super::sessions::AppError; + +#[derive(Debug, Deserialize)] +pub struct SearchQuery { + pub query: String, + pub page: Option, + pub page_size: Option, +} + +pub async fn search_messages( + Query(params): Query, +) -> Result, AppError> { + let db_path = crate::database::config::get_default_db_path() + .map_err(|e| AppError::Internal(format!("Failed to get database path: {e}")))?; + + let db_manager = Arc::new( + DatabaseManager::new(&db_path) + .await + .map_err(|e| AppError::Internal(format!("Failed to connect to database: {e}")))?, + ); + + let service = QueryService::with_database(db_manager); + + let request = SearchRequest { + query: params.query, + providers: None, + projects: None, + date_range: None, + search_type: None, + page: params.page, + page_size: params.page_size, + }; + + let response = service + .search_messages(request) + .await + .map_err(|e| AppError::Internal(format!("Search failed: {e}")))?; + + Ok(Json(serde_json::to_value(response).unwrap())) +} diff --git a/src/web/handlers/sessions.rs b/src/web/handlers/sessions.rs new file mode 100644 index 0000000..92cdebc --- /dev/null +++ b/src/web/handlers/sessions.rs @@ -0,0 +1,115 @@ +use axum::{ + extract::{Path, Query}, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Deserialize; +use std::sync::Arc; + +use crate::database::DatabaseManager; +use crate::services::{ + QueryService, SessionDetailRequest, SessionFilters, SessionsQueryRequest, +}; + +#[derive(Debug, Deserialize)] +pub struct SessionsQuery { + pub page: Option, + pub page_size: Option, + pub sort_by: Option, + pub sort_order: Option, + pub provider: Option, + pub project: Option, +} + +pub async fn list_sessions( + Query(params): Query, +) -> Result, AppError> { + let db_path = crate::database::config::get_default_db_path() + .map_err(|e| AppError::Internal(format!("Failed to get database path: {e}")))?; + + let db_manager = Arc::new( + DatabaseManager::new(&db_path) + .await + .map_err(|e| AppError::Internal(format!("Failed to connect to database: {e}")))?, + ); + + let service = QueryService::with_database(db_manager); + + let filters = if params.provider.is_some() || params.project.is_some() { + Some(SessionFilters { + provider: params.provider, + project: params.project, + date_range: None, + min_messages: None, + max_messages: None, + }) + } else { + None + }; + + let request = SessionsQueryRequest { + page: params.page, + page_size: params.page_size, + sort_by: params.sort_by, + sort_order: params.sort_order, + filters, + }; + + let response = service + .query_sessions(request) + .await + .map_err(|e| AppError::Internal(format!("Failed to query sessions: {e}")))?; + + Ok(Json(serde_json::to_value(response).unwrap())) +} + +pub async fn get_session_detail( + Path(session_id): Path, +) -> Result, AppError> { + let db_path = crate::database::config::get_default_db_path() + .map_err(|e| AppError::Internal(format!("Failed to get database path: {e}")))?; + + let db_manager = Arc::new( + DatabaseManager::new(&db_path) + .await + .map_err(|e| AppError::Internal(format!("Failed to connect to database: {e}")))?, + ); + + let service = QueryService::with_database(db_manager); + + let request = SessionDetailRequest { + session_id: session_id.clone(), + include_content: Some(true), + message_limit: None, + message_offset: None, + }; + + let response = service + .get_session_detail(request) + .await + .map_err(|_e| AppError::NotFound(format!("Session not found: {session_id}")))?; + + Ok(Json(serde_json::to_value(response).unwrap())) +} + +// Custom error type for better error handling +pub enum AppError { + NotFound(String), + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, message) = match self { + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + }; + + let body = Json(serde_json::json!({ + "error": message, + })); + + (status, body).into_response() + } +} diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..55476c4 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,5 @@ +pub mod handlers; +pub mod routes; +pub mod server; + +pub use server::run_server; diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs new file mode 100644 index 0000000..1db8787 --- /dev/null +++ b/src/web/routes/mod.rs @@ -0,0 +1,23 @@ +use axum::{routing::get, Router}; +use tower_http::services::ServeDir; + +use crate::web::handlers; + +pub fn create_routes() -> Router { + // API routes + let api_routes = Router::new() + .route("/sessions", get(handlers::list_sessions)) + .route("/sessions/:id", get(handlers::get_session_detail)) + .route("/search", get(handlers::search_messages)) + .route("/health", get(handlers::health_check)); + + // Static files - serve from embedded directory at compile time + let static_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("src") + .join("web") + .join("static"); + + Router::new() + .nest("/api", api_routes) + .nest_service("/", ServeDir::new(static_dir)) +} diff --git a/src/web/server.rs b/src/web/server.rs new file mode 100644 index 0000000..1b78bb5 --- /dev/null +++ b/src/web/server.rs @@ -0,0 +1,28 @@ +use anyhow::Result; +use tower_http::cors::CorsLayer; +use tower_http::trace::TraceLayer; + +use crate::web::routes; + +pub async fn run_server(host: &str, port: u16) -> Result<()> { + // Create the router with all routes + let app = routes::create_routes() + .layer(CorsLayer::permissive()) + .layer(TraceLayer::new_for_http()); + + // Bind to the address + let addr = format!("{host}:{port}"); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + // Print startup message + println!("๐ŸŒ RetroChat Web UI running at http://{addr}"); + println!("๐Ÿ“Š API available at http://{addr}/api"); + println!("๐Ÿฅ Health check: http://{addr}/api/health"); + println!(); + println!("Press Ctrl+C to stop the server"); + + // Start the server + axum::serve(listener, app).await?; + + Ok(()) +} diff --git a/src/web/static/app.js b/src/web/static/app.js new file mode 100644 index 0000000..53a7c82 --- /dev/null +++ b/src/web/static/app.js @@ -0,0 +1,399 @@ +// API Base URL +const API_BASE = '/api'; + +// State +let currentPage = 1; +let currentProvider = ''; +let currentSortBy = 'start_time'; +let isSearchMode = false; + +// Elements +const sessionsView = document.getElementById('sessions-view'); +const searchResultsView = document.getElementById('search-results-view'); +const sessionsList = document.getElementById('sessions-list'); +const searchResults = document.getElementById('search-results'); +const searchInput = document.getElementById('search-input'); +const searchBtn = document.getElementById('search-btn'); +const clearSearchBtn = document.getElementById('clear-search-btn'); +const providerFilter = document.getElementById('provider-filter'); +const sortBySelect = document.getElementById('sort-by'); +const prevPageBtn = document.getElementById('prev-page'); +const nextPageBtn = document.getElementById('next-page'); +const pageInfo = document.getElementById('page-info'); +const refreshBtn = document.getElementById('refresh-btn'); +const modal = document.getElementById('session-modal'); +const closeModalBtn = document.getElementById('close-modal'); +const modalTitle = document.getElementById('modal-title'); +const modalBody = document.getElementById('modal-body'); +const totalSessionsEl = document.getElementById('total-sessions'); +const statusEl = document.getElementById('status'); + +// Initialize +async function init() { + checkHealth(); + loadSessions(); + setupEventListeners(); +} + +// Check API health +async function checkHealth() { + try { + const response = await fetch(`${API_BASE}/health`); + const data = await response.json(); + if (data.status === 'ok') { + updateStatus('Ready', true); + } + } catch (error) { + updateStatus('Error', false); + console.error('Health check failed:', error); + } +} + +// Update status indicator +function updateStatus(text, isOk) { + const statusDot = statusEl.querySelector('.status-dot'); + // Remove all text nodes and re-add the dot and text + statusEl.textContent = ''; + statusEl.appendChild(statusDot); + statusEl.appendChild(document.createTextNode(` ${text}`)); + + if (isOk) { + statusDot.style.background = 'var(--success)'; + } else { + statusDot.style.background = 'var(--error)'; + statusDot.style.animation = 'none'; + } +} + +// Setup event listeners +function setupEventListeners() { + searchBtn.addEventListener('click', handleSearch); + clearSearchBtn.addEventListener('click', clearSearch); + searchInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') handleSearch(); + }); + + providerFilter.addEventListener('change', () => { + currentProvider = providerFilter.value; + currentPage = 1; + loadSessions(); + }); + + sortBySelect.addEventListener('change', () => { + currentSortBy = sortBySelect.value; + currentPage = 1; + loadSessions(); + }); + + prevPageBtn.addEventListener('click', () => { + if (currentPage > 1) { + currentPage--; + loadSessions(); + } + }); + + nextPageBtn.addEventListener('click', () => { + currentPage++; + loadSessions(); + }); + + refreshBtn.addEventListener('click', () => { + if (isSearchMode) { + handleSearch(); + } else { + loadSessions(); + } + }); + + closeModalBtn.addEventListener('click', closeModal); + modal.addEventListener('click', (e) => { + if (e.target === modal) closeModal(); + }); + + // Close modal on Escape key + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && modal.classList.contains('active')) { + closeModal(); + } + }); +} + +// Load sessions +async function loadSessions() { + try { + sessionsList.innerHTML = '
Loading sessions...
'; + + const params = new URLSearchParams({ + page: currentPage, + page_size: 20, + sort_by: currentSortBy, + sort_order: 'desc' + }); + + if (currentProvider) { + params.append('provider', currentProvider); + } + + const response = await fetch(`${API_BASE}/sessions?${params}`); + const data = await response.json(); + + renderSessions(data); + updatePagination(data); + totalSessionsEl.textContent = data.total_count; + } catch (error) { + console.error('Error loading sessions:', error); + sessionsList.innerHTML = '

Error loading sessions

Please try again later.

'; + } +} + +// Render sessions +function renderSessions(data) { + if (!data.sessions || data.sessions.length === 0) { + sessionsList.innerHTML = '

No sessions found

Try importing some chat history first.

'; + return; + } + + sessionsList.innerHTML = data.sessions.map(session => ` +
+
+
+ ${session.provider} + ${session.project ? `${session.project}` : ''} +
+
+ + ${session.message_count} messages + + ${session.total_tokens ? `${session.total_tokens.toLocaleString()} tokens` : ''} +
+
+
+ + ${formatDate(session.start_time)} + + ${session.has_retrospection ? 'Has Retrospection' : ''} +
+
${escapeHtml(session.first_message_preview)}
+
+ `).join(''); +} + +// Update pagination +function updatePagination(data) { + pageInfo.textContent = `Page ${data.page} of ${data.total_pages}`; + prevPageBtn.disabled = data.page <= 1; + nextPageBtn.disabled = data.page >= data.total_pages; +} + +// Handle search +async function handleSearch() { + const query = searchInput.value.trim(); + if (!query) return; + + try { + isSearchMode = true; + sessionsView.style.display = 'none'; + searchResultsView.style.display = 'block'; + clearSearchBtn.style.display = 'inline-block'; + + searchResults.innerHTML = '
Searching...
'; + + const params = new URLSearchParams({ + query: query, + page: 1, + page_size: 50 + }); + + const response = await fetch(`${API_BASE}/search?${params}`); + const data = await response.json(); + + renderSearchResults(data); + } catch (error) { + console.error('Error searching:', error); + searchResults.innerHTML = '

Search failed

Please try again.

'; + } +} + +// Render search results +function renderSearchResults(data) { + const searchStats = document.getElementById('search-stats'); + searchStats.textContent = `Found ${data.total_count} results in ${data.search_duration_ms}ms`; + + if (!data.results || data.results.length === 0) { + searchResults.innerHTML = '

No results found

Try a different search query.

'; + return; + } + + searchResults.innerHTML = data.results.map(result => ` +
+
+ ${result.provider} +
+ ${formatDate(result.timestamp)} + ${result.message_role} +
+
+ ${result.project ? `
${result.project}
` : ''} +
${highlightSearchTerm(escapeHtml(result.content_snippet), searchInput.value)}
+
+ `).join(''); +} + +// Clear search +function clearSearch() { + isSearchMode = false; + searchInput.value = ''; + sessionsView.style.display = 'block'; + searchResultsView.style.display = 'none'; + clearSearchBtn.style.display = 'none'; + loadSessions(); +} + +// Load session detail +async function loadSessionDetail(sessionId) { + try { + modal.classList.add('active'); + modalTitle.textContent = 'Session Details'; + modalBody.innerHTML = '
Loading session details...
'; + + const response = await fetch(`${API_BASE}/sessions/${sessionId}`); + const data = await response.json(); + + renderSessionDetail(data); + } catch (error) { + console.error('Error loading session detail:', error); + modalBody.innerHTML = '

Error loading session

Please try again.

'; + } +} + +// Render session detail +function renderSessionDetail(data) { + const { session, messages } = data; + + modalTitle.textContent = `${session.provider} Session`; + + modalBody.innerHTML = ` +
+
+

Session Information

+
+
+ Provider + + ${session.provider} + +
+ ${session.project_name ? ` +
+ Project + ${session.project_name} +
+ ` : ''} +
+ Messages + ${session.message_count} +
+ ${session.token_count ? ` +
+ Tokens + ${session.token_count.toLocaleString()} +
+ ` : ''} +
+ Started + ${formatDate(session.start_time)} +
+ ${session.end_time ? ` +
+ Ended + ${formatDate(session.end_time)} +
+ ` : ''} + ${session.file_path ? ` +
+ File Path + ${escapeHtml(session.file_path)} +
+ ` : ''} +
+
+ +
+

Messages (${messages.length})

+
+ ${messages.map(msg => ` +
+
+ ${msg.role} + ${formatTime(msg.timestamp)} +
+
${escapeHtml(msg.content)}
+ ${msg.tool_uses && msg.tool_uses.length > 0 ? ` +
+ Tools Used: + ${msg.tool_uses.map(tool => `
${tool.name}
`).join('')} +
+ ` : ''} +
+ `).join('')} +
+
+
+ `; +} + +// Close modal +function closeModal() { + modal.classList.remove('active'); +} + +// Utility functions +function formatDate(dateString) { + const date = new Date(dateString); + const now = new Date(); + const diffMs = now - date; + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 7) return `${diffDays}d ago`; + + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +function formatTime(dateString) { + const date = new Date(dateString); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function highlightSearchTerm(text, term) { + if (!term) return text; + const regex = new RegExp(`(${escapeRegex(term)})`, 'gi'); + return text.replace(regex, '$1'); +} + +function escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +// Start the app +init(); diff --git a/src/web/static/index.html b/src/web/static/index.html new file mode 100644 index 0000000..0f1bb91 --- /dev/null +++ b/src/web/static/index.html @@ -0,0 +1,109 @@ + + + + + + RetroChat - LLM Chat History Viewer + + + +
+ +
+
+ +
+
+ Total Sessions + - +
+
+ Status + + + +
+
+
+
+ + +
+ +
+ + +
+
+ + +
+ +
+
+

Sessions

+ +
+
+
Loading sessions...
+
+ +
+ + + +
+ + + +
+ + + + diff --git a/src/web/static/style.css b/src/web/static/style.css new file mode 100644 index 0000000..09b69ea --- /dev/null +++ b/src/web/static/style.css @@ -0,0 +1,608 @@ +:root { + --background: #111111; + --surface: #1a1a1a; + --border: #333333; + --text-primary: #e5e5e5; + --text-secondary: #888888; + --accent: #007aff; + --accent-hover: #0056b3; + --success: #28a745; + --warning: #ffc107; + --error: #dc3545; + --shadow: rgba(0, 0, 0, 0.5); + --radius: 8px; + --radius-sm: 4px; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + background: var(--background); + color: var(--text-primary); + line-height: 1.5; + min-height: 100vh; +} + +.container { + max-width: 1400px; + margin: 0 auto; + padding: 20px; +} + +/* Header */ +.header { + background: var(--surface); + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: 20px; + margin-bottom: 24px; +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 20px; +} + +.logo h1 { + font-size: 1.5rem; + font-weight: 600; + margin-bottom: 4px; +} + +.subtitle { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.header-stats { + display: flex; + gap: 24px; +} + +.stat { + display: flex; + flex-direction: column; + align-items: flex-end; +} + +.stat-label { + font-size: 0.8rem; + color: var(--text-secondary); + margin-bottom: 4px; + text-transform: uppercase; +} + +.stat-value { + font-size: 1.2rem; + font-weight: 600; +} + +.status-indicator { + display: flex; + align-items: center; + gap: 6px; +} + +.status-dot { + width: 8px; + height: 8px; + background: var(--success); + border-radius: 50%; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Search Section */ +.search-section { + margin-bottom: 24px; +} + +.search-bar { + display: flex; + gap: 12px; + margin-bottom: 12px; +} + +.search-bar input { + flex: 1; + padding: 10px 14px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 1rem; + transition: all 0.2s; +} + +.search-bar input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 122, 255, 0.2); +} + +.filters { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.filter-select { + padding: 8px 12px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9rem; + cursor: pointer; + transition: all 0.2s; +} + +.filter-select:hover { + border-color: var(--accent); +} + +.filter-select:focus { + outline: none; + border-color: var(--accent); +} + +/* Buttons */ +.btn { + padding: 8px 16px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.btn-primary { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.btn-primary:hover { + background: var(--accent-hover); + border-color: var(--accent-hover); +} + +.btn-secondary { + background: var(--surface); + color: var(--text-primary); + border-color: var(--border); +} + +.btn-secondary:hover { + background: var(--border); +} + +.btn-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + padding: 8px; + font-size: 1rem; +} + +/* Main Content */ +.main-content { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 0; +} + +.section-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.section-header h2 { + font-size: 1.2rem; + font-weight: 600; +} + +/* Sessions List */ +.sessions-list { + margin-bottom: 0; +} + +.session-card { + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: 16px 20px; + cursor: pointer; + transition: background-color 0.2s; +} + +.session-card:last-child { + border-bottom: none; +} + +.session-card:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +.session-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 8px; +} + +.session-title { + display: flex; + align-items: center; + gap: 10px; +} + +.provider-badge { + padding: 3px 10px; + border-radius: var(--radius-sm); + font-size: 0.8rem; + font-weight: 500; + border: 1px solid; +} + +.provider-badge.claude { + border-color: #ff6b35; + color: #ff6b35; +} + +.provider-badge.cursor { + border-color: #3b82f6; + color: #3b82f6; +} + +.provider-badge.gemini { + border-color: #4285f4; + color: #4285f4; +} + +.provider-badge.codex { + border-color: #ffffff; + color: #ffffff; +} + +.session-meta { + display: flex; + gap: 16px; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.session-meta-item { + display: flex; + align-items: center; + gap: 6px; +} + +.session-preview { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.4; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 1; + -webkit-box-orient: vertical; +} + +/* Search Results */ +.search-stats { + color: var(--text-secondary); + font-size: 0.9rem; +} + +.search-results { + display: grid; + gap: 0; +} + +.search-result { + background: transparent; + border: none; + border-bottom: 1px solid var(--border); + border-radius: 0; + padding: 16px 20px; + cursor: pointer; + transition: background-color 0.2s; +} + +.search-result:last-child { + border-bottom: none; +} + +.search-result:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +.search-result-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.search-result-meta { + display: flex; + gap: 12px; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.search-result-content { + color: var(--text-primary); + line-height: 1.5; +} + +/* Pagination */ +.pagination { + display: flex; + justify-content: center; + align-items: center; + gap: 16px; + padding: 16px 20px; + border-top: 1px solid var(--border); +} + +#page-info { + color: var(--text-secondary); + font-size: 0.9rem; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + z-index: 1000; + padding: 20px; + overflow-y: auto; +} + +.modal.active { + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 40px; +} + +.modal-content { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + max-width: 900px; + width: 100%; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px var(--shadow); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.modal-close { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.5rem; + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: all 0.2s; +} + +.modal-close:hover { + background: var(--background); + color: var(--text-primary); +} + +.modal-body { + padding: 20px; + overflow-y: auto; +} + +.session-detail { + display: grid; + gap: 16px; +} + +.detail-section { + background: var(--background); + border-radius: var(--radius-sm); + border: 1px solid var(--border); + padding: 16px; +} + +.detail-section h3 { + margin-bottom: 12px; + color: var(--text-primary); + font-size: 1.1rem; +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.detail-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.detail-label { + font-size: 0.8rem; + color: var(--text-secondary); + text-transform: uppercase; +} + +.detail-value { + font-size: 0.95rem; + color: var(--text-primary); +} + +/* Messages */ +.messages { + display: grid; + gap: 12px; +} + +.message { + background: transparent; + border: 1px solid var(--border); + border-left-width: 3px; + border-radius: var(--radius-sm); + padding: 12px; +} + +.message.user { + border-left-color: var(--accent); +} + +.message.assistant { + border-left-color: var(--success); +} + +.message.system { + border-left-color: var(--warning); +} + +.message-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.message-role { + font-weight: 600; + text-transform: capitalize; + font-size: 0.9rem; +} + +.message-time { + color: var(--text-secondary); + font-size: 0.8rem; +} + +.message-content { + line-height: 1.6; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 0.95rem; +} + +/* Loading */ +.loading { + text-align: center; + padding: 40px; + color: var(--text-secondary); +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 60px 20px; + color: var(--text-secondary); +} + +.empty-state h3 { + font-size: 1.2rem; + margin-bottom: 8px; + color: var(--text-primary); +} + +/* Responsive */ +@media (max-width: 768px) { + .header-content { + flex-direction: column; + align-items: flex-start; + } + + .header-stats { + width: 100%; + justify-content: space-between; + } + + .search-bar { + flex-direction: column; + } + + .filters { + flex-direction: column; + } + + .filter-select { + width: 100%; + } + + .session-header { + flex-direction: column; + gap: 12px; + } + + .detail-grid { + grid-template-columns: 1fr; + } +} + +/* Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--background); +} + +::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} From 6277e73d7befffa5100fd2efef6fa9a9e9dd0e32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=B3=91=EC=9A=B1?= Date: Mon, 20 Oct 2025 00:34:47 +0900 Subject: [PATCH 2/6] feat: add project_path extraction for all providers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add project_path field to chat_sessions to store the full working directory path where each session was executed, complementing the existing project_name field. Implementation: - Add project_path column to chat_sessions table with index - Extract paths from provider-specific data sources: - Codex: Direct from session_meta.cwd field - Cursor: Infer from .cursor/chats/{hash}/{uuid}/store.db file structure - Claude Code: Frequency-based selection from message-level cwd fields - Gemini: Fallback extraction from thoughts (with Rainbow Table support planned) - Add Rainbow Table for hash-to-path mapping (Gemini support) - Display project_path in Web UI session detail view Tests: - Add 7 comprehensive parser tests covering happy paths and edge cases - All existing tests pass with model updates Dependencies: - Add sha2 for Rainbow Table hashing - Add regex for Gemini path extraction ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.lock | 2 + Cargo.toml | 2 + docs/project_path_extraction.md | 63 ++++++---- migrations/007_add_project_path.sql | 6 + src/database/chat_session_repo.rs | 19 +-- src/models/chat_session.rs | 7 ++ src/parsers/claude_code.rs | 31 +++++ src/parsers/codex.rs | 5 + src/parsers/cursor_agent.rs | 44 +++++++ src/parsers/gemini_cli.rs | 121 ++++++++++++++++++- src/parsers/mod.rs | 1 + src/parsers/project_path_rainbow_table.rs | 136 ++++++++++++++++++++++ src/tui/state/session_detail_state.rs | 3 + src/web/handlers/sessions.rs | 4 +- src/web/static/app.js | 6 + tests/unit/test_claude_code_parser.rs | 78 +++++++++++++ tests/unit/test_codex_parser.rs | 47 ++++++++ tests/unit/test_cursor_agent_parser.rs | 84 +++++++++++++ 18 files changed, 622 insertions(+), 37 deletions(-) create mode 100644 migrations/007_add_project_path.sql create mode 100644 src/parsers/project_path_rainbow_table.rs diff --git a/Cargo.lock b/Cargo.lock index a563d51..a6a9d4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2298,10 +2298,12 @@ dependencies = [ "num_cpus", "prost", "ratatui", + "regex", "reqwest", "rusqlite", "serde", "serde_json", + "sha2", "similar", "sqlx", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 5ace0fd..77c2783 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,8 @@ axum = "0.7" tower = "0.4" tower-http = { version = "0.5", features = ["cors", "fs", "trace"] } mime_guess = "2.0" +sha2 = "0.10" +regex = "1.10" [features] default = ["reqwest"] diff --git a/docs/project_path_extraction.md b/docs/project_path_extraction.md index 48ef362..b36e49f 100644 --- a/docs/project_path_extraction.md +++ b/docs/project_path_extraction.md @@ -40,45 +40,60 @@ pub struct ChatSession { โ””โ”€โ”€ 61ac7e7d-8fdd-46f9-8d8e-4793aeeac69b.jsonl ``` -#### ๋ฐ์ดํ„ฐ ํฌ๋งท +#### ๋ฐ์ดํ„ฐ ํฌ๋งท (์‹ค์ œ ํ™•์ธ) ```json { - "type": "conversation", - "sessionId": "550e8400-e29b-41d4-a716-446655440000", - "timestamp": "2024-01-01T10:00:00Z", + "type": "user", + "cwd": "/Users/lullu/study/retrochat", + "gitBranch": "main", + "sessionId": "01601fb2-7e83-4bb9-8fc1-c736a632fcfa", "message": { "role": "user", "content": "Hello" - } + }, + "timestamp": "2025-10-19T14:49:59.496Z" } ``` #### Project Path ์ถ”์ถœ ๋ฐฉ๋ฒ• -**โœ… ๊ฐ€๋Šฅ - ํŒŒ์ผ๋ช… ๋””๋ ‰ํ† ๋ฆฌ ํŒจํ„ด ๋””์ฝ”๋”ฉ** +**โœ… ๊ฐ€๋Šฅ - ๋ฉ”์‹œ์ง€ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ์— ์ง์ ‘ ์ œ๊ณต!** -Claude Code๋Š” ํ”„๋กœ์ ํŠธ ๊ฒฝ๋กœ๋ฅผ ๋””๋ ‰ํ† ๋ฆฌ๋ช…์— ์ธ์ฝ”๋”ฉํ•ฉ๋‹ˆ๋‹ค: -- ์ธ์ฝ”๋”ฉ ํŒจํ„ด: `-Users-lullu-study-retrochat` -- ์›๋ณธ ๊ฒฝ๋กœ: `/Users/lullu/study/retrochat` +Claude Code๋Š” **๋ชจ๋“  user/assistant ๋ฉ”์‹œ์ง€์— `cwd` ํ•„๋“œ๋ฅผ ํฌํ•จ**ํ•ฉ๋‹ˆ๋‹ค: +- `cwd`: ์‹ค์ œ ์ž‘์—… ๋””๋ ‰ํ† ๋ฆฌ ์ „์ฒด ๊ฒฝ๋กœ +- `gitBranch`: Git ๋ธŒ๋žœ์น˜ ์ •๋ณด (bonus!) -**ํ˜„์žฌ ๊ตฌํ˜„**: -- `ProjectInference` (`src/parsers/project_inference.rs`)๊ฐ€ ์ด๋ฏธ ์ด ํŒจํ„ด์„ ํŒŒ์‹ฑํ•˜์ง€๋งŒ, **ํ”„๋กœ์ ํŠธ ์ด๋ฆ„๋งŒ** ์ถ”์ถœ -- Line 16-45: `infer_project_name()` - ๋งˆ์ง€๋ง‰ ๋””๋ ‰ํ† ๋ฆฌ ์ด๋ฆ„๋งŒ ๋ฐ˜ํ™˜ +**์ค‘์š” ๋ฐœ๊ฒฌ**: +- ๋Œ€๋ถ€๋ถ„์˜ ์„ธ์…˜์€ ๋‹จ์ผ `cwd` ์‚ฌ์šฉ (90%+) +- ์ผ๋ถ€ ์„ธ์…˜์€ ์—ฌ๋Ÿฌ `cwd` ์‚ฌ์šฉ (์˜ˆ: worktree ์ด๋™) + ``` + {'/Users/lullu/study/retrochat', + '/Users/lullu/study/retrochat/.worktree/feature-ux-improvements'} + ``` + +**๊ตฌํ˜„ ์ „๋žต**: +- **๋นˆ๋„ ๊ธฐ๋ฐ˜ ์„ ํƒ**: ์„ธ์…˜์—์„œ ๊ฐ€์žฅ ๋งŽ์ด ๋“ฑ์žฅํ•œ `cwd`๋ฅผ project_path๋กœ ์‚ฌ์šฉ +- ์ด์œ : ๋‹ค๋ฅธ provider์™€ ์ผ๊ด€์„ฑ ์œ ์ง€ (์„ธ์…˜๋‹น 1๊ฐœ์˜ ๋Œ€ํ‘œ ๊ฒฝ๋กœ) -**ํ•„์š”ํ•œ ์ž‘์—…**: ```rust -impl ProjectInference { - // ์ƒˆ๋กœ์šด ๋ฉ”์„œ๋“œ ์ถ”๊ฐ€ - pub fn infer_project_path(&self) -> Option { - // -Users-lullu-study-retrochat โ†’ /Users/lullu/study/retrochat - // resolve_original_path()๋ฅผ ํ™œ์šฉํ•˜์—ฌ ์ „์ฒด ๊ฒฝ๋กœ ๋ฐ˜ํ™˜ - } -} +// ์‹ค์ œ ๊ตฌํ˜„ ์˜ˆ์‹œ +let cwd_counts: HashMap = messages + .iter() + .filter_map(|entry| entry.cwd.as_ref()) + .fold(HashMap::new(), |mut acc, cwd| { + *acc.entry(cwd.clone()).or_insert(0) += 1; + acc + }); + +let project_path = cwd_counts + .into_iter() + .max_by_key(|(_, count)| *count) + .map(|(cwd, _)| cwd); ``` -**๊ตฌํ˜„ ๋‚œ์ด๋„**: โญ (์‰ฌ์›€) -- ๊ธฐ์กด `resolve_original_path()` ๋กœ์ง ํ™œ์šฉ ๊ฐ€๋Šฅ -- ์ด๋ฏธ ํŒŒ์ผ์‹œ์Šคํ…œ ๊ฒ€์ฆ ๋กœ์ง ์กด์žฌ +**๊ตฌํ˜„ ๋‚œ์ด๋„**: โญ (๋งค์šฐ ์‰ฌ์›€) +- ๋ฐ์ดํ„ฐ์— ์ง์ ‘ ์ œ๊ณต๋จ +- ๋นˆ๋„ ๊ณ„์‚ฐ๋งŒ ์ถ”๊ฐ€ ํ•„์š” --- @@ -390,8 +405,8 @@ if let Some(path) = &session.project_path { | Provider | ๋ฐ์ดํ„ฐ ๊ฐ€์šฉ์„ฑ | ์ถ”์ถœ ๋ฐฉ๋ฒ• | ์ •ํ™•๋„ | ๊ตฌํ˜„ ๋‚œ์ด๋„ | ๋น„๊ณ  | |----------|-------------|----------|--------|-----------|------| | **Codex** | โœ… ์ง์ ‘ ์ œ๊ณต | `meta.cwd` ํ•„๋“œ | 100% | โญ ๋งค์šฐ ์‰ฌ์›€ | `cwd` ํ•„๋“œ์— ์ „์ฒด ๊ฒฝ๋กœ ํฌํ•จ | +| **Claude Code** | โœ… ์ง์ ‘ ์ œ๊ณต | ๋ฉ”์‹œ์ง€ `cwd` ํ•„๋“œ (๋นˆ๋„ ๊ธฐ๋ฐ˜) | 100% | โญ ๋งค์šฐ ์‰ฌ์›€ | ๋ชจ๋“  ๋ฉ”์‹œ์ง€์— `cwd` + `gitBranch` ํฌํ•จ | | **Cursor** | โœ… ํŒŒ์ผ ๊ตฌ์กฐ | ๊ฒฝ๋กœ ์—ญ์ถ”์  (5x parent) | 100% | โญ ์‰ฌ์›€ | `.cursor/chats/...` ๊ตฌ์กฐ ํ™œ์šฉ | -| **Claude Code** | โœ… ํŒŒ์ผ๋ช… ํŒจํ„ด | ๋””๋ ‰ํ† ๋ฆฌ๋ช… ๋””์ฝ”๋”ฉ | 95% | โญโญ ์ค‘๊ฐ„ | `-Users-...` ํŒจํ„ด ํŒŒ์‹ฑ | | **Gemini CLI** | โŒ ์—†์Œ | ํŒŒ์ผ ์œ„์น˜ ์ถ”๋ก  OR ์ˆ˜๋™ ์ž…๋ ฅ | 30% | โญโญโญ ์–ด๋ ค์›€ | ๋ฐ์ดํ„ฐ์— ์ •๋ณด ์—†์Œ | --- diff --git a/migrations/007_add_project_path.sql b/migrations/007_add_project_path.sql new file mode 100644 index 0000000..90ae6d2 --- /dev/null +++ b/migrations/007_add_project_path.sql @@ -0,0 +1,6 @@ +-- Add project_path column to chat_sessions table +-- This stores the working directory path where the chat session was executed +ALTER TABLE chat_sessions ADD COLUMN project_path TEXT; + +-- Create index for faster queries on project_path +CREATE INDEX IF NOT EXISTS idx_chat_sessions_project_path ON chat_sessions(project_path); diff --git a/src/database/chat_session_repo.rs b/src/database/chat_session_repo.rs index ad13f7e..9aaadc8 100644 --- a/src/database/chat_session_repo.rs +++ b/src/database/chat_session_repo.rs @@ -41,15 +41,16 @@ impl ChatSessionRepository { sqlx::query( r#" INSERT INTO chat_sessions ( - id, provider, project_name, start_time, end_time, + id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "#, ) .bind(session.id.to_string()) .bind(session.provider.to_string()) .bind(session.project_name.as_ref()) + .bind(session.project_path.as_ref()) .bind(session.start_time.to_rfc3339()) .bind(session.end_time.map(|t| t.to_rfc3339())) .bind(session.message_count) @@ -69,7 +70,7 @@ impl ChatSessionRepository { pub async fn get_by_id(&self, id: &Uuid) -> AnyhowResult> { let row = sqlx::query( r#" - SELECT id, provider, project_name, start_time, end_time, + SELECT id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state FROM chat_sessions WHERE id = ? @@ -92,7 +93,7 @@ impl ChatSessionRepository { pub async fn get_all(&self) -> AnyhowResult> { let rows = sqlx::query( r#" - SELECT id, provider, project_name, start_time, end_time, + SELECT id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state FROM chat_sessions ORDER BY updated_at DESC @@ -156,7 +157,7 @@ impl ChatSessionRepository { pub async fn get_by_provider(&self, provider: &Provider) -> AnyhowResult> { let rows = sqlx::query( r#" - SELECT id, provider, project_name, start_time, end_time, + SELECT id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state FROM chat_sessions WHERE provider = ? ORDER BY updated_at DESC @@ -179,7 +180,7 @@ impl ChatSessionRepository { pub async fn get_by_project_name(&self, project_name: &str) -> AnyhowResult> { let rows = sqlx::query( r#" - SELECT id, provider, project_name, start_time, end_time, + SELECT id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state FROM chat_sessions WHERE project_name = ? ORDER BY updated_at DESC @@ -202,7 +203,7 @@ impl ChatSessionRepository { pub async fn get_by_file_hash(&self, file_hash: &str) -> AnyhowResult> { let row = sqlx::query( r#" - SELECT id, provider, project_name, start_time, end_time, + SELECT id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state FROM chat_sessions WHERE file_hash = ? @@ -245,7 +246,7 @@ impl ChatSessionRepository { pub async fn get_recent_sessions(&self, limit: i64) -> AnyhowResult> { let rows = sqlx::query( r#" - SELECT id, provider, project_name, start_time, end_time, + SELECT id, provider, project_name, project_path, start_time, end_time, message_count, token_count, file_path, file_hash, created_at, updated_at, state FROM chat_sessions ORDER BY updated_at DESC LIMIT ? @@ -269,6 +270,7 @@ impl ChatSessionRepository { let id_str: String = row.try_get("id")?; let provider_str: String = row.try_get("provider")?; let project_name: Option = row.try_get("project_name")?; + let project_path: Option = row.try_get("project_path")?; let start_time_str: String = row.try_get("start_time")?; let end_time_str: Option = row.try_get("end_time")?; let message_count: i64 = row.try_get("message_count")?; @@ -315,6 +317,7 @@ impl ChatSessionRepository { id, provider, project_name, + project_path, start_time, end_time, message_count: message_count as u32, diff --git a/src/models/chat_session.rs b/src/models/chat_session.rs index f37756a..ae039d2 100644 --- a/src/models/chat_session.rs +++ b/src/models/chat_session.rs @@ -42,6 +42,7 @@ pub struct ChatSession { pub id: Uuid, pub provider: Provider, pub project_name: Option, + pub project_path: Option, pub start_time: DateTime, pub end_time: Option>, pub message_count: u32, @@ -65,6 +66,7 @@ impl ChatSession { id: Uuid::new_v4(), provider, project_name: None, + project_path: None, start_time, end_time: None, message_count: 0, @@ -82,6 +84,11 @@ impl ChatSession { self } + pub fn with_project_path(mut self, project_path: String) -> Self { + self.project_path = Some(project_path); + self + } + pub fn with_end_time(mut self, end_time: DateTime) -> Self { self.end_time = Some(end_time); self diff --git a/src/parsers/claude_code.rs b/src/parsers/claude_code.rs index bc36ae2..a332d09 100644 --- a/src/parsers/claude_code.rs +++ b/src/parsers/claude_code.rs @@ -52,6 +52,8 @@ pub struct ClaudeCodeConversationEntry { /// Tool use result metadata (stdout, stderr, etc.) for tool_result messages #[serde(rename = "toolUseResult")] pub tool_use_result: Option, + /// Working directory where the session was executed + pub cwd: Option, } #[derive(Debug, Serialize, Deserialize)] @@ -205,6 +207,12 @@ impl ClaudeCodeParser { chat_session = chat_session.with_project(name); } + // Extract project_path from cwd using frequency-based selection + let project_path = self.extract_most_frequent_cwd(&entries); + if let Some(path) = project_path { + chat_session = chat_session.with_project_path(path); + } + // Convert conversation entries to messages let mut messages = Vec::new(); let mut total_tokens = 0u32; @@ -619,6 +627,29 @@ impl ClaudeCodeParser { Ok(()) } + + /// Extract the most frequently occurring cwd from conversation entries + /// Uses frequency-based selection to handle sessions with multiple working directories + fn extract_most_frequent_cwd(&self, entries: &[ClaudeCodeConversationEntry]) -> Option { + use std::collections::HashMap; + + // Count occurrences of each cwd + let mut cwd_counts: HashMap = HashMap::new(); + + for entry in entries { + if let Some(cwd) = &entry.cwd { + if !cwd.is_empty() { + *cwd_counts.entry(cwd.clone()).or_insert(0) += 1; + } + } + } + + // Find the cwd with the highest frequency + cwd_counts + .into_iter() + .max_by_key(|(_, count)| *count) + .map(|(cwd, _)| cwd) + } } #[cfg(test)] diff --git a/src/parsers/codex.rs b/src/parsers/codex.rs index 7ed12c5..a989065 100644 --- a/src/parsers/codex.rs +++ b/src/parsers/codex.rs @@ -267,6 +267,11 @@ impl CodexParser { chat_session = chat_session.with_project(name); } + // Set project_path from cwd if available + if let Some(cwd) = &meta.cwd { + chat_session = chat_session.with_project_path(cwd.clone()); + } + let mut converted_messages = Vec::new(); let mut estimated_total_tokens = 0u32; diff --git a/src/parsers/cursor_agent.rs b/src/parsers/cursor_agent.rs index ad4c595..e82fb16 100644 --- a/src/parsers/cursor_agent.rs +++ b/src/parsers/cursor_agent.rs @@ -156,6 +156,13 @@ impl CursorAgentParser { chat_session = chat_session.with_project(name); } + // Infer project path from database path + let project_path = self.infer_project_path(); + + if let Some(path) = project_path { + chat_session = chat_session.with_project_path(path); + } + // Parse blobs to extract messages let messages = self.read_blobs(session_id, start_time)?; @@ -417,6 +424,43 @@ impl CursorAgentParser { None } + fn infer_project_path(&self) -> Option { + use std::path::PathBuf; + + let path = PathBuf::from(&self.db_path); + + // Path structure: .../project_dir/.cursor/chats/{hash}/{uuid}/store.db + // We want to extract the full path of project_dir + + if let Some(uuid_dir) = path.parent() { + if let Some(hash_dir) = uuid_dir.parent() { + if let Some(chats_dir) = hash_dir.parent() { + // chats_dir is .cursor/chats + if let Some(cursor_dir) = chats_dir.parent() { + // cursor_dir is .cursor + if let Some(project_dir) = cursor_dir.parent() { + // This is the actual project directory + if let Some(project_name) = project_dir.file_name() { + let name = project_name.to_string_lossy().to_string(); + // Skip generic names like "Users", "home", etc. + if !name.starts_with('.') + && name != "Users" + && name != "home" + && name.len() > 1 + { + // Return the full path to project directory + return Some(project_dir.to_string_lossy().to_string()); + } + } + } + } + } + } + } + + None + } + fn read_metadata(&self) -> Result { let conn = rusqlite::Connection::open(&self.db_path) .with_context(|| format!("Failed to open Cursor database: {}", self.db_path))?; diff --git a/src/parsers/gemini_cli.rs b/src/parsers/gemini_cli.rs index dd9791a..a744e4f 100644 --- a/src/parsers/gemini_cli.rs +++ b/src/parsers/gemini_cli.rs @@ -300,8 +300,16 @@ impl GeminiCLIParser { chat_session.id = session_id; chat_session = chat_session.with_end_time(end_time); - // Use project_hash as project name if available - if let Some(project_hash) = &session.project_hash { + // Extract project_path from thoughts (fallback method for now) + // TODO: Implement Rainbow Table lookup for better accuracy + if let Some(project_path) = self.extract_project_path_from_thoughts(&session) { + chat_session = chat_session.with_project_path(project_path.clone()); + + // Extract project name from path + if let Some(project_name) = Self::extract_project_name_from_path(&project_path) { + chat_session = chat_session.with_project(project_name); + } + } else if let Some(project_hash) = &session.project_hash { // Use the full project_hash prefix as the project name chat_session = chat_session.with_project(project_hash.clone()); } else { @@ -838,6 +846,115 @@ impl GeminiCLIParser { Ok(gemini_export.conversations.len()) } + + /// Extract project path from thoughts (Fallback method) + /// + /// Parses file paths from `thoughts[].description` and infers the project root + /// Based on research in docs/gemini_project_path_research.md + /// + /// Accuracy: ~85%, Coverage: ~70% + fn extract_project_path_from_thoughts(&self, session: &GeminiSession) -> Option { + use regex::Regex; + + // Regex pattern to match absolute file paths + let filepath_regex = Regex::new(r"/Users/[^\s]+").ok()?; + let function_response_regex = Regex::new(r"--- (/Users/[^\s]+) ---").ok()?; + + let mut file_paths = Vec::new(); + + // Extract file paths from thoughts + for message in &session.messages { + if let Some(thoughts) = &message.thoughts { + for thought in thoughts { + for cap in filepath_regex.find_iter(&thought.description) { + file_paths.push(cap.as_str().to_string()); + } + } + } + + // Also check content for Function Response patterns + if message.content.contains("[Function Response") { + for cap in function_response_regex.captures_iter(&message.content) { + if let Some(path) = cap.get(1) { + file_paths.push(path.as_str().to_string()); + } + } + } + } + + if file_paths.is_empty() { + return None; + } + + // Filter out unwanted paths + let filtered_paths: Vec<_> = file_paths + .into_iter() + .filter(|p| { + !p.contains(".venv") + && !p.contains(".pyenv") + && !p.contains("node_modules") + && !p.contains("site-packages") + && !p.contains(".cargo") + && !p.contains(".local/share") + }) + .collect(); + + if filtered_paths.is_empty() { + return None; + } + + // Infer project root from file paths + Self::infer_project_root_from_paths(&filtered_paths) + } + + /// Infer project root directory from file paths + /// + /// Looks for common project structure markers (src/, lib/, etc.) and + /// returns the parent directory as the project root + fn infer_project_root_from_paths(paths: &[String]) -> Option { + use std::path::PathBuf; + + let project_markers = ["src", "lib", "test", "tests", "docs", "config", "scripts"]; + + for path_str in paths { + let path = PathBuf::from(path_str); + let components: Vec<_> = path.components().collect(); + + for (i, component) in components.iter().enumerate() { + if let Some(name) = component.as_os_str().to_str() { + if project_markers.contains(&name) && i > 0 { + // Return the path up to (but not including) the marker + let project_path: PathBuf = components[..i].iter().collect(); + return Some(project_path.to_string_lossy().to_string()); + } + } + } + } + + // Fallback: Find the shortest common prefix + if let Some(first) = paths.first() { + let first_path = PathBuf::from(first); + if let Some(parent) = first_path.parent() { + if let Some(project_root) = parent.parent() { + return Some(project_root.to_string_lossy().to_string()); + } + } + } + + None + } + + /// Extract project name from a project path + /// + /// Returns the last component of the path (directory name) + fn extract_project_name_from_path(path: &str) -> Option { + use std::path::Path; + + Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .map(|s| s.to_string()) + } } #[cfg(test)] diff --git a/src/parsers/mod.rs b/src/parsers/mod.rs index 6b7ae16..5563e4d 100644 --- a/src/parsers/mod.rs +++ b/src/parsers/mod.rs @@ -3,6 +3,7 @@ pub mod codex; pub mod cursor_agent; pub mod gemini_cli; pub mod project_inference; +pub mod project_path_rainbow_table; use anyhow::{anyhow, Result}; use std::path::Path; diff --git a/src/parsers/project_path_rainbow_table.rs b/src/parsers/project_path_rainbow_table.rs new file mode 100644 index 0000000..36f4713 --- /dev/null +++ b/src/parsers/project_path_rainbow_table.rs @@ -0,0 +1,136 @@ +use sha2::{Digest, Sha256}; +use std::collections::HashMap; + +/// Cross-Provider Rainbow Table for mapping projectHash to project_path +/// +/// Gemini CLI stores only SHA256 hashes of project paths, making reverse lookup impossible. +/// This Rainbow Table collects project_path values from other providers (Claude, Cursor, Codex) +/// and creates a hash-to-path mapping, allowing us to recover the original path for Gemini sessions. +/// +/// Expected Coverage: 60-95% depending on provider combination +/// Accuracy: 100% (SHA256 collision-free in practice) +#[derive(Debug, Clone)] +pub struct ProjectPathRainbowTable { + hash_to_path: HashMap, +} + +impl ProjectPathRainbowTable { + /// Create a new empty Rainbow Table + pub fn new() -> Self { + Self { + hash_to_path: HashMap::new(), + } + } + + /// Add a project path to the Rainbow Table + /// + /// Computes SHA256 hash and stores the mapping + pub fn add_path(&mut self, path: &str) { + if path.is_empty() { + return; + } + + let hash = Self::compute_hash(path); + self.hash_to_path.insert(hash, path.to_string()); + } + + /// Compute SHA256 hash of a project path (matching Gemini CLI's implementation) + /// + /// Gemini CLI uses: `crypto.createHash('sha256').update(projectRoot).digest('hex')` + pub fn compute_hash(path: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(path.as_bytes()); + format!("{:x}", hasher.finalize()) + } + + /// Lookup project path by hash + /// + /// Returns the original project path if found in the Rainbow Table + pub fn lookup(&self, project_hash: &str) -> Option<&String> { + self.hash_to_path.get(project_hash) + } + + /// Get the number of entries in the Rainbow Table + pub fn len(&self) -> usize { + self.hash_to_path.len() + } + + /// Check if the Rainbow Table is empty + pub fn is_empty(&self) -> bool { + self.hash_to_path.is_empty() + } + + /// Verify if a project path matches the given hash + pub fn verify(path: &str, expected_hash: &str) -> bool { + let computed_hash = Self::compute_hash(path); + computed_hash == expected_hash + } +} + +impl Default for ProjectPathRainbowTable { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_compute_hash() { + // Test vectors from the research document + let path1 = "/Users/lullu/study/retrochat"; + let expected1 = "658435c1dc7019e23d4d83de3afb2fdde4012c7a6122680b5be7c8698ce0e516"; + assert_eq!(ProjectPathRainbowTable::compute_hash(path1), expected1); + + let path2 = "/Users/lullu/projects/x37"; + let expected2 = "b7c62724c472dd2ca9fdc8305211b643b0d7f3a29825d8c80891e84bd5647f48"; + assert_eq!(ProjectPathRainbowTable::compute_hash(path2), expected2); + + let path3 = "/Users/lullu/turing/gpai/gpai-monorepo-3"; + let expected3 = "f80b91b389f45890e9ed84c0e946f4514a27619060d440aa28896b40da3265fe"; + assert_eq!(ProjectPathRainbowTable::compute_hash(path3), expected3); + } + + #[test] + fn test_add_and_lookup() { + let mut table = ProjectPathRainbowTable::new(); + + table.add_path("/Users/lullu/study/retrochat"); + table.add_path("/Users/lullu/projects/x37"); + + let hash1 = "658435c1dc7019e23d4d83de3afb2fdde4012c7a6122680b5be7c8698ce0e516"; + assert_eq!( + table.lookup(hash1), + Some(&"/Users/lullu/study/retrochat".to_string()) + ); + + let hash2 = "b7c62724c472dd2ca9fdc8305211b643b0d7f3a29825d8c80891e84bd5647f48"; + assert_eq!( + table.lookup(hash2), + Some(&"/Users/lullu/projects/x37".to_string()) + ); + + let nonexistent = "0000000000000000000000000000000000000000000000000000000000000000"; + assert_eq!(table.lookup(nonexistent), None); + } + + #[test] + fn test_verify() { + let path = "/Users/lullu/study/retrochat"; + let correct_hash = "658435c1dc7019e23d4d83de3afb2fdde4012c7a6122680b5be7c8698ce0e516"; + let wrong_hash = "0000000000000000000000000000000000000000000000000000000000000000"; + + assert!(ProjectPathRainbowTable::verify(path, correct_hash)); + assert!(!ProjectPathRainbowTable::verify(path, wrong_hash)); + } + + #[test] + fn test_empty_path() { + let mut table = ProjectPathRainbowTable::new(); + table.add_path(""); + + assert_eq!(table.len(), 0); // Empty paths should not be added + } +} diff --git a/src/tui/state/session_detail_state.rs b/src/tui/state/session_detail_state.rs index 7a0a2ec..fec067c 100644 --- a/src/tui/state/session_detail_state.rs +++ b/src/tui/state/session_detail_state.rs @@ -257,6 +257,7 @@ mod tests { id: uuid::Uuid::new_v4(), provider: Provider::ClaudeCode, project_name: None, + project_path: None, start_time: Utc::now(), end_time: None, message_count: 5, @@ -291,6 +292,7 @@ mod tests { id: uuid::Uuid::new_v4(), provider: Provider::ClaudeCode, project_name: None, + project_path: None, start_time: Utc::now(), end_time: None, message_count: 5, @@ -310,6 +312,7 @@ mod tests { id: uuid::Uuid::new_v4(), provider: Provider::ClaudeCode, project_name: None, + project_path: None, start_time: Utc::now(), end_time: None, message_count: 3, diff --git a/src/web/handlers/sessions.rs b/src/web/handlers/sessions.rs index 92cdebc..93bbc43 100644 --- a/src/web/handlers/sessions.rs +++ b/src/web/handlers/sessions.rs @@ -8,9 +8,7 @@ use serde::Deserialize; use std::sync::Arc; use crate::database::DatabaseManager; -use crate::services::{ - QueryService, SessionDetailRequest, SessionFilters, SessionsQueryRequest, -}; +use crate::services::{QueryService, SessionDetailRequest, SessionFilters, SessionsQueryRequest}; #[derive(Debug, Deserialize)] pub struct SessionsQuery { diff --git a/src/web/static/app.js b/src/web/static/app.js index 53a7c82..d18d94f 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -309,6 +309,12 @@ function renderSessionDetail(data) { ${formatDate(session.end_time)} ` : ''} + ${session.project_path ? ` +
+ Project Path + ${escapeHtml(session.project_path)} +
+ ` : ''} ${session.file_path ? `
File Path diff --git a/tests/unit/test_claude_code_parser.rs b/tests/unit/test_claude_code_parser.rs index f44ab46..d446879 100644 --- a/tests/unit/test_claude_code_parser.rs +++ b/tests/unit/test_claude_code_parser.rs @@ -455,3 +455,81 @@ async fn test_claude_conversation_multiple_tool_results() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_claude_code_parser_project_path_from_single_cwd() -> Result<()> { + let mut temp_file = NamedTempFile::with_suffix(".jsonl").unwrap(); + // Conversation format with cwd in each entry + let sample_data = r#"{"type":"user","cwd":"/Users/test/myproject","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:00:00Z","message":{"role":"user","content":"Hello"}} +{"type":"assistant","cwd":"/Users/test/myproject","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:01:00Z","message":{"role":"assistant","content":"Hi"}}"#; + + temp_file.write_all(sample_data.as_bytes()).unwrap(); + + let parser = ClaudeCodeParser::new(temp_file.path()); + let result = parser.parse().await; + + assert!(result.is_ok()); + let (session, _messages) = result.unwrap(); + + // Should have extracted project_path from cwd + assert_eq!( + session.project_path, + Some("/Users/test/myproject".to_string()) + ); + + // Note: project_name is inferred from file path (not cwd) via ProjectInference, + // so we don't assert it here since NamedTempFile creates random paths + + Ok(()) +} + +#[tokio::test] +async fn test_claude_code_parser_project_path_from_multiple_cwds() -> Result<()> { + let mut temp_file = NamedTempFile::with_suffix(".jsonl").unwrap(); + // Multiple messages with different cwds - frequency-based selection + // worktree path appears 2 times, main path appears 4 times -> main should win + let sample_data = r#"{"type":"user","cwd":"/Users/test/myproject","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:00:00Z","message":{"role":"user","content":"msg1"}} +{"type":"assistant","cwd":"/Users/test/myproject","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:01:00Z","message":{"role":"assistant","content":"msg2"}} +{"type":"user","cwd":"/Users/test/myproject/.worktree/feature-x","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:02:00Z","message":{"role":"user","content":"msg3"}} +{"type":"assistant","cwd":"/Users/test/myproject/.worktree/feature-x","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:03:00Z","message":{"role":"assistant","content":"msg4"}} +{"type":"user","cwd":"/Users/test/myproject","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:04:00Z","message":{"role":"user","content":"msg5"}} +{"type":"assistant","cwd":"/Users/test/myproject","sessionId":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:05:00Z","message":{"role":"assistant","content":"msg6"}}"#; + + temp_file.write_all(sample_data.as_bytes()).unwrap(); + + let parser = ClaudeCodeParser::new(temp_file.path()); + let result = parser.parse().await; + + assert!(result.is_ok()); + let (session, messages) = result.unwrap(); + + assert_eq!(messages.len(), 6); + + // Should have selected the most frequent cwd (main path appears 4 times) + assert_eq!( + session.project_path, + Some("/Users/test/myproject".to_string()) + ); + + Ok(()) +} + +#[tokio::test] +async fn test_claude_code_parser_project_path_without_cwd() -> Result<()> { + let mut temp_file = NamedTempFile::with_suffix(".jsonl").unwrap(); + // Old format without cwd field + let sample_data = r#"{"uuid":"550e8400-e29b-41d4-a716-446655440000","created_at":"2024-01-01T10:00:00Z","updated_at":"2024-01-01T10:00:00Z","chat_messages":[{"uuid":"550e8400-e29b-41d4-a716-446655440001","content":"Hello","created_at":"2024-01-01T10:00:00Z","updated_at":"2024-01-01T10:00:00Z","role":"user"}]}"#; + + temp_file.write_all(sample_data.as_bytes()).unwrap(); + + let parser = ClaudeCodeParser::new(temp_file.path()); + let result = parser.parse().await; + + assert!(result.is_ok()); + let (session, _messages) = result.unwrap(); + + // Should not have project_path when cwd is not available + assert_eq!(session.project_path, None); + + Ok(()) +} diff --git a/tests/unit/test_codex_parser.rs b/tests/unit/test_codex_parser.rs index b953063..6378133 100644 --- a/tests/unit/test_codex_parser.rs +++ b/tests/unit/test_codex_parser.rs @@ -249,3 +249,50 @@ async fn test_codex_parser_multiple_content_items() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_codex_parser_project_path_from_cwd() -> Result<()> { + let mut temp_file = NamedTempFile::with_suffix(".jsonl").unwrap(); + let sample_data = r#"{"timestamp":"2025-10-12T14:10:16.717Z","type":"session_meta","payload":{"id":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:00:00Z","cwd":"/Users/test/myproject"}} +{"timestamp":"2025-10-12T17:53:40.556Z","type":"event_msg","payload":{"type":"user_message","message":"Hello"}}"#; + + temp_file.write_all(sample_data.as_bytes()).unwrap(); + + let parser = CodexParser::new(temp_file.path()); + let result = parser.parse().await; + + assert!(result.is_ok()); + let (session, _messages) = result.unwrap(); + + // Should have extracted project_path from cwd + assert_eq!( + session.project_path, + Some("/Users/test/myproject".to_string()) + ); + + // Should also have extracted project_name from cwd + assert_eq!(session.project_name, Some("myproject".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn test_codex_parser_project_path_without_cwd() -> Result<()> { + let mut temp_file = NamedTempFile::with_suffix(".jsonl").unwrap(); + // No cwd in session_meta + let sample_data = r#"{"timestamp":"2025-10-12T14:10:16.717Z","type":"session_meta","payload":{"id":"550e8400-e29b-41d4-a716-446655440000","timestamp":"2024-01-01T10:00:00Z"}} +{"timestamp":"2025-10-12T17:53:40.556Z","type":"event_msg","payload":{"type":"user_message","message":"Hello"}}"#; + + temp_file.write_all(sample_data.as_bytes()).unwrap(); + + let parser = CodexParser::new(temp_file.path()); + let result = parser.parse().await; + + assert!(result.is_ok()); + let (session, _messages) = result.unwrap(); + + // Should not have project_path when cwd is missing + assert_eq!(session.project_path, None); + + Ok(()) +} diff --git a/tests/unit/test_cursor_agent_parser.rs b/tests/unit/test_cursor_agent_parser.rs index 9cd2c46..086df17 100644 --- a/tests/unit/test_cursor_agent_parser.rs +++ b/tests/unit/test_cursor_agent_parser.rs @@ -381,3 +381,87 @@ async fn test_cursor_parser_no_tools() -> Result<()> { Ok(()) } + +#[tokio::test] +async fn test_cursor_parser_project_path_inference() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create project directory structure: /path/to/myproject/.cursor/chats/{hash}/{uuid}/store.db + let project_dir = base_path.join("myproject"); + let cursor_dir = project_dir.join(".cursor"); + let chats_dir = cursor_dir.join("chats"); + let hash_dir = chats_dir.join("53460df9022de1a66445a5b78b067dd9"); + let uuid_dir = hash_dir.join("557abc41-6f00-41e7-bf7b-696c80d4ee94"); + fs::create_dir_all(&uuid_dir).unwrap(); + + let store_db = uuid_dir.join("store.db"); + + // Create database with metadata + let conn = rusqlite::Connection::open(&store_db).unwrap(); + conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)", []) + .unwrap(); + conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)", []) + .unwrap(); + + let test_metadata = r#"{"agentId":"557abc41-6f00-41e7-bf7b-696c80d4ee94","latestRootBlobId":"test","name":"Test","mode":"default","createdAt":1758872189097,"lastUsedModel":"claude-3-5-sonnet"}"#; + let hex_metadata = hex::encode(test_metadata.as_bytes()); + conn.execute( + "INSERT INTO meta (key, value) VALUES ('0', ?)", + [&hex_metadata], + ) + .unwrap(); + + let parser = CursorAgentParser::new(&store_db); + let (session, _messages) = parser.parse().await?; + + // Should have inferred project_path from directory structure + assert!(session.project_path.is_some()); + let project_path = session.project_path.unwrap(); + assert!(project_path.ends_with("myproject")); + + // Should also have inferred project_name + assert_eq!(session.project_name, Some("myproject".to_string())); + + Ok(()) +} + +#[tokio::test] +async fn test_cursor_parser_project_path_with_generic_name() -> Result<()> { + let temp_dir = TempDir::new().unwrap(); + let base_path = temp_dir.path(); + + // Create structure where parent is "Users" (should be skipped) + // /Users/.cursor/chats/{hash}/{uuid}/store.db + let users_dir = base_path.join("Users"); + let cursor_dir = users_dir.join(".cursor"); + let chats_dir = cursor_dir.join("chats"); + let hash_dir = chats_dir.join("53460df9022de1a66445a5b78b067dd9"); + let uuid_dir = hash_dir.join("557abc41-6f00-41e7-bf7b-696c80d4ee94"); + fs::create_dir_all(&uuid_dir).unwrap(); + + let store_db = uuid_dir.join("store.db"); + + // Create database + let conn = rusqlite::Connection::open(&store_db).unwrap(); + conn.execute("CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB)", []) + .unwrap(); + conn.execute("CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT)", []) + .unwrap(); + + let test_metadata = r#"{"agentId":"557abc41-6f00-41e7-bf7b-696c80d4ee94","latestRootBlobId":"test","name":"Test","mode":"default","createdAt":1758872189097,"lastUsedModel":"claude-3-5-sonnet"}"#; + let hex_metadata = hex::encode(test_metadata.as_bytes()); + conn.execute( + "INSERT INTO meta (key, value) VALUES ('0', ?)", + [&hex_metadata], + ) + .unwrap(); + + let parser = CursorAgentParser::new(&store_db); + let (session, _messages) = parser.parse().await?; + + // Should not have project_path because parent dir is "Users" (generic name) + assert_eq!(session.project_path, None); + + Ok(()) +} From 6ef777371571044565092601d45110064fee8aa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=B3=91=EC=9A=B1?= Date: Mon, 20 Oct 2025 00:55:13 +0900 Subject: [PATCH 3/6] chore: change default web server port from 3000 to 7878 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changed the default port to avoid conflicts with commonly used ports. Port 7878 is less commonly used and reduces collision risk. ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 85dfa3d..d37bfda 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -97,7 +97,7 @@ pub enum Commands { /// Launch Web UI server Web { /// Port to run the web server on - #[arg(short, long, default_value = "3000")] + #[arg(short, long, default_value = "7878")] port: u16, /// Host to bind to From c488d8a94e1accacdb1f2f3b15a5c3af760361c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=B3=91=EC=9A=B1?= Date: Mon, 20 Oct 2025 01:02:55 +0900 Subject: [PATCH 4/6] refactor: fix clippy warnings and improve code quality MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused Confirm import in setup.rs - Refactor handle_timeline_command to use TimelineOptions struct (reduce 10 args to 1) - Fix wildcard pattern in match expression - Remove needless question mark operator in time_parser - Add regex and sha2 dependencies to Cargo.toml ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- Cargo.lock | 2 ++ Cargo.toml | 2 ++ src/cli/mod.rs | 4 +-- src/cli/query.rs | 53 +++++++++++++++++++++++++--------------- src/cli/setup.rs | 2 +- src/utils/time_parser.rs | 4 +-- 6 files changed, 42 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6fab531..ba419a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2366,10 +2366,12 @@ dependencies = [ "num_cpus", "prost", "ratatui", + "regex", "reqwest", "rusqlite", "serde", "serde_json", + "sha2", "similar", "sqlx", "tempfile", diff --git a/Cargo.toml b/Cargo.toml index 9b9ef74..a62bf50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ axum = "0.7" tower = "0.4" tower-http = { version = "0.5", features = ["fs", "cors", "trace"] } mime_guess = "2.0" +regex = "1.10" +sha2 = "0.10" [features] default = ["reqwest"] diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 4fa0000..8b0e38a 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -302,7 +302,7 @@ impl Cli { truncate_head, truncate_tail, } => { - query::handle_timeline_command( + query::handle_timeline_command(query::TimelineOptions { since, until, provider, @@ -313,7 +313,7 @@ impl Cli { no_truncate, truncate_head, truncate_tail, - ) + }) .await } }, diff --git a/src/cli/query.rs b/src/cli/query.rs index 7aa5c65..311e87f 100644 --- a/src/cli/query.rs +++ b/src/cli/query.rs @@ -5,6 +5,19 @@ use crate::utils::time_parser; use anyhow::Result; use std::sync::Arc; +pub struct TimelineOptions { + pub since: Option, + pub until: Option, + pub provider: Option, + pub role: Option, + pub format: String, + pub limit: Option, + pub reverse: bool, + pub no_truncate: bool, + pub truncate_head: usize, + pub truncate_tail: usize, +} + pub async fn handle_sessions_command( page: Option, page_size: Option, @@ -148,26 +161,15 @@ pub async fn handle_search_command(query: String, limit: Option) -> Result< Ok(()) } -pub async fn handle_timeline_command( - since: Option, - until: Option, - provider: Option, - role: Option, - format: String, - limit: Option, - reverse: bool, - no_truncate: bool, - truncate_head: usize, - truncate_tail: usize, -) -> Result<()> { +pub async fn handle_timeline_command(options: TimelineOptions) -> Result<()> { // Parse time specifications - let from = if let Some(since_str) = since { + let from = if let Some(since_str) = options.since { Some(time_parser::parse_time_spec(&since_str)?) } else { None }; - let to = if let Some(until_str) = until { + let to = if let Some(until_str) = options.until { Some(time_parser::parse_time_spec(&until_str)?) } else { None @@ -183,17 +185,28 @@ pub async fn handle_timeline_command( .get_by_time_range( from, to, - provider.as_deref(), - role.as_deref(), - limit.map(|l| l as i64), - reverse, + options.provider.as_deref(), + options.role.as_deref(), + options.limit.map(|l| l as i64), + options.reverse, ) .await?; // Format output - match format.as_str() { + match options.format.as_str() { "jsonl" => format_jsonl(&messages), - "compact" | _ => format_compact(&messages, !no_truncate, truncate_head, truncate_tail), + "compact" => format_compact( + &messages, + !options.no_truncate, + options.truncate_head, + options.truncate_tail, + ), + _ => format_compact( + &messages, + !options.no_truncate, + options.truncate_head, + options.truncate_tail, + ), } Ok(()) diff --git a/src/cli/setup.rs b/src/cli/setup.rs index 67580bd..5c2dc53 100644 --- a/src/cli/setup.rs +++ b/src/cli/setup.rs @@ -1,7 +1,7 @@ use anyhow::Result; use console::{style, Emoji}; use indicatif::{ProgressBar, ProgressStyle}; -use inquire::{Confirm, MultiSelect}; +use inquire::MultiSelect; use std::sync::Arc; use crate::database::{config, DatabaseManager}; diff --git a/src/utils/time_parser.rs b/src/utils/time_parser.rs index ab450bf..a1bc9c8 100644 --- a/src/utils/time_parser.rs +++ b/src/utils/time_parser.rs @@ -30,10 +30,10 @@ pub fn parse_time_spec(spec: &str) -> Result> { // 4. Short date: 2024-10-19 if let Ok(date) = NaiveDate::parse_from_str(spec, "%Y-%m-%d") { - return Ok(Utc + return Utc .from_local_datetime(&date.and_hms_opt(0, 0, 0).unwrap()) .single() - .context("Ambiguous date/time")?); + .context("Ambiguous date/time"); } // 5. Relative time: "7 days ago", "1 week ago", "yesterday" From c0553db28942e686abf1c8803b169d6917bdc7ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=B3=91=EC=9A=B1?= Date: Mon, 20 Oct 2025 01:15:25 +0900 Subject: [PATCH 5/6] feat: add Timeline feature to web UI with CLI-style text view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented comprehensive Timeline feature for browsing messages by time range: **Backend (Rust)** - New `/api/timeline` endpoint with time range queries - Support for relative time ("1 hour ago", "7 days ago", etc.) - Filter by provider, role, and message limit - Custom SQL JOIN to fetch provider/project info **Frontend** - Clean 2-tab navigation: Browse | Timeline - URL-based routing with query parameters (?tab=timeline) - Time range picker with quick filters (1h, 12h, today, yesterday, 7d, 30d) - Compact/Full display modes - Character and token count display (~4 chars = 1 token) - Copy All and Export JSONL functionality - CLI-style monospace text output - Individual message copy buttons **UI/UX Improvements** - Unified Browse tab (merged Sessions + Search) - Consistent layout across all tabs - Stats displayed in header (messages, chars, tokens) - Action buttons logically grouped - Default limit: 1000 messages - Provider-specific color coding (๐ŸŸ  Claude, ๐Ÿ”ต Gemini, ๐Ÿ”ท Cursor, โšช Codex) **Technical Details** - Browser history API integration (back/forward support) - Event delegation for dynamic content - Modular code structure (timeline.js separate from app.js) - Responsive flexbox layouts ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/web/handlers/mod.rs | 2 + src/web/handlers/timeline.rs | 158 ++++++++++++++++ src/web/routes/mod.rs | 1 + src/web/static/app.js | 81 +++++++++ src/web/static/index.html | 82 ++++++++- src/web/static/style.css | 271 +++++++++++++++++++++++++++ src/web/static/timeline.js | 343 +++++++++++++++++++++++++++++++++++ 7 files changed, 936 insertions(+), 2 deletions(-) create mode 100644 src/web/handlers/timeline.rs create mode 100644 src/web/static/timeline.js diff --git a/src/web/handlers/mod.rs b/src/web/handlers/mod.rs index 24bd8ba..18b8f53 100644 --- a/src/web/handlers/mod.rs +++ b/src/web/handlers/mod.rs @@ -1,7 +1,9 @@ pub mod health; pub mod search; pub mod sessions; +pub mod timeline; pub use health::health_check; pub use search::search_messages; pub use sessions::{get_session_detail, list_sessions}; +pub use timeline::query_timeline; diff --git a/src/web/handlers/timeline.rs b/src/web/handlers/timeline.rs new file mode 100644 index 0000000..c59cc1d --- /dev/null +++ b/src/web/handlers/timeline.rs @@ -0,0 +1,158 @@ +use axum::{extract::Query, Json}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::FromRow; +use std::sync::Arc; + +use crate::database::DatabaseManager; +use crate::utils::time_parser; + +use super::sessions::AppError; + +#[derive(Debug, Deserialize)] +pub struct TimelineQuery { + pub since: Option, + pub until: Option, + pub provider: Option, + pub role: Option, + pub limit: Option, + pub reverse: Option, +} + +#[derive(Debug, Serialize, FromRow)] +pub struct TimelineMessage { + pub timestamp: DateTime, + pub role: String, + pub provider: String, + pub project: Option, + pub session_id: String, + pub content: String, + pub message_id: String, +} + +#[derive(Debug, Serialize)] +pub struct TimelineResponse { + pub messages: Vec, + pub total_count: usize, + pub time_range: TimeRange, +} + +#[derive(Debug, Serialize)] +pub struct TimeRange { + pub from: Option>, + pub to: Option>, +} + +pub async fn query_timeline( + Query(params): Query, +) -> Result, AppError> { + // Parse time specifications + let from = if let Some(since_str) = params.since { + Some( + time_parser::parse_time_spec(&since_str) + .map_err(|e| AppError::Internal(format!("Invalid 'since' time: {e}")))?, + ) + } else { + None + }; + + let to = if let Some(until_str) = params.until { + Some( + time_parser::parse_time_spec(&until_str) + .map_err(|e| AppError::Internal(format!("Invalid 'until' time: {e}")))?, + ) + } else { + None + }; + + // Get database + let db_path = crate::database::config::get_default_db_path() + .map_err(|e| AppError::Internal(format!("Failed to get database path: {e}")))?; + + let db_manager = Arc::new( + DatabaseManager::new(&db_path) + .await + .map_err(|e| AppError::Internal(format!("Failed to connect to database: {e}")))?, + ); + + // Build custom SQL query with JOIN to get provider and project + let mut sql = String::from( + r#" + SELECT + m.timestamp, + m.role as role, + cs.provider as provider, + cs.project_path as project, + m.session_id as session_id, + m.content as content, + m.id as message_id + FROM messages m + JOIN chat_sessions cs ON cs.id = m.session_id + "#, + ); + + let mut conditions = Vec::new(); + + if from.is_some() { + conditions.push("m.timestamp >= ?"); + } + + if to.is_some() { + conditions.push("m.timestamp <= ?"); + } + + if params.provider.is_some() { + conditions.push("cs.provider = ?"); + } + + if params.role.is_some() { + conditions.push("m.role = ?"); + } + + if !conditions.is_empty() { + sql.push_str(" WHERE "); + sql.push_str(&conditions.join(" AND ")); + } + + sql.push_str(" ORDER BY m.timestamp "); + sql.push_str(if params.reverse.unwrap_or(false) { + "DESC" + } else { + "ASC" + }); + + if let Some(limit) = params.limit { + sql.push_str(&format!(" LIMIT {}", limit)); + } + + let mut query = sqlx::query_as::<_, TimelineMessage>(&sql); + + if let Some(from_time) = from { + query = query.bind(from_time.to_rfc3339()); + } + + if let Some(to_time) = to { + query = query.bind(to_time.to_rfc3339()); + } + + if let Some(provider) = ¶ms.provider { + query = query.bind(provider); + } + + if let Some(role) = ¶ms.role { + query = query.bind(role); + } + + let timeline_messages = query + .fetch_all(db_manager.pool()) + .await + .map_err(|e| AppError::Internal(format!("Failed to query timeline: {e}")))?; + + let total_count = timeline_messages.len(); + + Ok(Json(TimelineResponse { + messages: timeline_messages, + total_count, + time_range: TimeRange { from, to }, + })) +} diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index 1db8787..17b4fc8 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -9,6 +9,7 @@ pub fn create_routes() -> Router { .route("/sessions", get(handlers::list_sessions)) .route("/sessions/:id", get(handlers::get_session_detail)) .route("/search", get(handlers::search_messages)) + .route("/timeline", get(handlers::query_timeline)) .route("/health", get(handlers::health_check)); // Static files - serve from embedded directory at compile time diff --git a/src/web/static/app.js b/src/web/static/app.js index d18d94f..249c3c9 100644 --- a/src/web/static/app.js +++ b/src/web/static/app.js @@ -33,6 +33,8 @@ async function init() { checkHealth(); loadSessions(); setupEventListeners(); + setupTabNavigation(); + Timeline.init(); } // Check API health @@ -385,6 +387,85 @@ function formatTime(dateString) { }); } +// Tab Navigation +function setupTabNavigation() { + const tabButtons = document.querySelectorAll('.tab-btn'); + const views = { + sessions: document.getElementById('sessions-view'), + timeline: document.getElementById('timeline-view'), + }; + const controls = { + sessions: document.getElementById('sessions-controls'), + timeline: document.getElementById('timeline-controls'), + }; + + // Also handle search results view + const searchResultsView = document.getElementById('search-results-view'); + + // Function to switch to a specific tab + function switchTab(tab, updateHistory = true) { + // Update active tab button + tabButtons.forEach(b => b.classList.remove('active')); + const targetBtn = document.querySelector(`.tab-btn[data-tab="${tab}"]`); + if (targetBtn) { + targetBtn.classList.add('active'); + } + + // Show/hide views + Object.values(views).forEach(v => v.style.display = 'none'); + searchResultsView.style.display = 'none'; + + if (views[tab]) { + views[tab].style.display = 'block'; + } + + // Show/hide controls + Object.values(controls).forEach(c => c.style.display = 'none'); + if (controls[tab]) { + controls[tab].style.display = 'block'; + } + + // Reset search state when switching tabs + if (tab !== 'sessions') { + isSearchMode = false; + } + + // Update URL with query parameter + if (updateHistory) { + const url = tab === 'sessions' ? '/' : `/?tab=${tab}`; + window.history.pushState({ tab }, '', url); + } + } + + // Tab button click handlers + tabButtons.forEach(btn => { + btn.addEventListener('click', () => { + const tab = btn.dataset.tab; + switchTab(tab); + }); + }); + + // Handle browser back/forward buttons + window.addEventListener('popstate', (event) => { + if (event.state && event.state.tab) { + switchTab(event.state.tab, false); + } else { + // Check URL for tab parameter + const params = new URLSearchParams(window.location.search); + const tab = params.get('tab') || 'sessions'; + switchTab(tab, false); + } + }); + + // Initialize based on URL query parameter + const params = new URLSearchParams(window.location.search); + const initialTab = params.get('tab') || 'sessions'; + switchTab(initialTab, false); + // Set initial history state + const initialUrl = initialTab === 'sessions' ? '/' : `/?tab=${initialTab}`; + window.history.replaceState({ tab: initialTab }, '', initialUrl); +} + function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; diff --git a/src/web/static/index.html b/src/web/static/index.html index 0f1bb91..88a77d9 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -30,8 +30,14 @@

RetroChat

- -
+ + + + +
+ + +
@@ -88,6 +140,31 @@

Search Results

+ + + @@ -104,6 +181,7 @@ + diff --git a/src/web/static/style.css b/src/web/static/style.css index 09b69ea..ab46d3f 100644 --- a/src/web/static/style.css +++ b/src/web/static/style.css @@ -209,6 +209,16 @@ body { border-radius: var(--radius); border: 1px solid var(--border); padding: 0; + min-height: 500px; +} + +/* All views should have same structure */ +.sessions-view, +.search-results-view, +.timeline-view { + display: flex; + flex-direction: column; + min-height: 500px; } .section-header { @@ -217,6 +227,7 @@ body { align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); + flex-shrink: 0; } .section-header h2 { @@ -227,6 +238,7 @@ body { /* Sessions List */ .sessions-list { margin-bottom: 0; + flex: 1; } .session-card { @@ -321,6 +333,7 @@ body { .search-results { display: grid; gap: 0; + flex: 1; } .search-result { @@ -368,6 +381,7 @@ body { gap: 16px; padding: 16px 20px; border-top: 1px solid var(--border); + flex-shrink: 0; } #page-info { @@ -606,3 +620,260 @@ body { ::-webkit-scrollbar-thumb:hover { background: var(--text-secondary); } + +/* Tab Navigation */ +.tab-navigation { + display: flex; + gap: 4px; + margin-bottom: 20px; + background: var(--surface); + padding: 4px; + border-radius: var(--radius); + border: 1px solid var(--border); +} + +.tab-btn { + flex: 1; + padding: 10px 20px; + background: transparent; + color: var(--text-secondary); + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + transition: all 0.2s; +} + +.tab-btn:hover { + background: rgba(255, 255, 255, 0.05); + color: var(--text-primary); +} + +.tab-btn.active { + background: var(--accent); + color: white; +} + +/* Tab Controls */ +.tab-controls { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 16px; + margin-bottom: 20px; + min-height: 80px; /* Ensure consistent height */ +} + +/* Timeline Filters */ +.timeline-filters { + display: flex; + flex-direction: column; + gap: 16px; +} + +.time-range { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; +} + +.time-input-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.time-input-group label { + font-size: 0.85rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.time-input { + padding: 8px 12px; + background: var(--background); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 0.9rem; + min-width: 150px; +} + +.time-input:focus { + outline: none; + border-color: var(--accent); +} + +.quick-filters { + display: flex; + gap: 8px; + flex-wrap: wrap; +} + +.quick-filter-btn { + padding: 6px 12px; + background: var(--background); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s; +} + +.quick-filter-btn:hover { + background: var(--border); + color: var(--text-primary); +} + +.timeline-options { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.timeline-actions { + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: center; +} + +.timeline-header-left { + display: flex; + align-items: center; + gap: 16px; +} + +.timeline-header-stats { + display: flex; + gap: 8px; + align-items: center; +} + +.timeline-stats-separator { + color: var(--border); + font-size: 0.85rem; +} + +.timeline-header-actions { + display: flex; + gap: 8px; + align-items: center; +} + +/* Timeline View */ +.timeline-view { + /* Inherits from common view styles above */ +} + +.timeline-output { + background: var(--background); + border: none; + border-radius: 0; + padding: 16px 20px; + min-height: 400px; + max-height: 70vh; + overflow-y: auto; + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; + font-size: 0.9rem; + line-height: 1.6; + flex: 1; +} + +.timeline-empty { + color: var(--text-secondary); + text-align: center; + padding: 40px 20px; +} + +.timeline-stats { + font-size: 0.9rem; + color: var(--text-secondary); +} + +/* Timeline Message */ +.timeline-message { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border); +} + +.timeline-message:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.timeline-message-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 0.85rem; +} + +.timeline-time { + color: var(--text-secondary); +} + +.timeline-role { + font-family: monospace; + color: var(--text-secondary); +} + +.timeline-provider { + color: var(--text-primary); +} + +.timeline-project { + color: var(--text-secondary); +} + +.timeline-copy-msg { + margin-left: auto; + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + font-size: 0.8rem; + transition: all 0.2s; +} + +.timeline-copy-msg:hover { + background: var(--border); + color: var(--text-primary); +} + +.timeline-message-content { + color: var(--text-primary); + white-space: pre-wrap; + word-wrap: break-word; + padding-left: 8px; +} + +.timeline-truncated { + color: var(--text-secondary); + font-size: 0.8rem; + font-style: italic; + margin-top: 8px; + padding-left: 8px; +} + +/* Error message */ +.error-message { + background: rgba(220, 53, 69, 0.1); + border: 1px solid var(--error); + border-radius: var(--radius-sm); + padding: 12px 16px; + color: var(--error); +} + +.error-message strong { + font-weight: 600; +} diff --git a/src/web/static/timeline.js b/src/web/static/timeline.js new file mode 100644 index 0000000..22cca95 --- /dev/null +++ b/src/web/static/timeline.js @@ -0,0 +1,343 @@ +// Timeline Module +const Timeline = { + // State + currentMessages: [], + displayMode: 'compact', // 'compact' or 'full' + + // Elements + elements: { + output: null, + stats: null, + sinceInput: null, + untilInput: null, + providerSelect: null, + roleSelect: null, + modeSelect: null, + limitInput: null, + queryBtn: null, + copyBtn: null, + exportBtn: null, + charCount: null, + tokenCount: null, + }, + + // Initialize + init() { + this.elements = { + output: document.getElementById('timeline-output'), + stats: document.getElementById('timeline-stats'), + sinceInput: document.getElementById('timeline-since'), + untilInput: document.getElementById('timeline-until'), + providerSelect: document.getElementById('timeline-provider'), + roleSelect: document.getElementById('timeline-role'), + modeSelect: document.getElementById('timeline-mode'), + limitInput: document.getElementById('timeline-limit'), + queryBtn: document.getElementById('timeline-query-btn'), + copyBtn: document.getElementById('timeline-copy-btn'), + exportBtn: document.getElementById('timeline-export-btn'), + charCount: document.getElementById('timeline-char-count'), + tokenCount: document.getElementById('timeline-token-count'), + }; + + this.setupEventListeners(); + }, + + // Setup event listeners + setupEventListeners() { + // Query button + if (this.elements.queryBtn) { + this.elements.queryBtn.addEventListener('click', () => this.query()); + } + + // Quick filter buttons - use event delegation for dynamically loaded content + document.addEventListener('click', (e) => { + if (e.target.classList.contains('quick-filter-btn')) { + e.preventDefault(); + const since = e.target.dataset.since; + this.elements.sinceInput.value = since; + this.elements.untilInput.value = 'now'; + } + }); + + // Copy all button + if (this.elements.copyBtn) { + this.elements.copyBtn.addEventListener('click', () => this.copyAll()); + } + + // Export JSONL button + if (this.elements.exportBtn) { + this.elements.exportBtn.addEventListener('click', () => this.exportJSONL()); + } + + // Enter key in inputs + [this.elements.sinceInput, this.elements.untilInput, this.elements.limitInput].forEach(input => { + if (input) { + input.addEventListener('keypress', (e) => { + if (e.key === 'Enter') this.query(); + }); + } + }); + + // Mode change + if (this.elements.modeSelect) { + this.elements.modeSelect.addEventListener('change', (e) => { + this.displayMode = e.target.value; + if (this.currentMessages.length > 0) { + // Re-render with new mode + this.renderMessages({ messages: this.currentMessages, total_count: this.currentMessages.length }); + } + }); + } + }, + + // Query timeline + async query() { + const params = new URLSearchParams(); + + const since = this.elements.sinceInput.value.trim(); + const until = this.elements.untilInput.value.trim(); + const provider = this.elements.providerSelect.value; + const role = this.elements.roleSelect.value; + const limit = this.elements.limitInput.value; + + if (since) params.append('since', since); + if (until) params.append('until', until); + if (provider) params.append('provider', provider); + if (role) params.append('role', role); + if (limit) params.append('limit', limit); + + try { + this.showLoading(); + const response = await fetch(`/api/timeline?${params}`); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Query failed'); + } + + const data = await response.json(); + this.currentMessages = data.messages; + this.renderMessages(data); + this.updateStats(data); + + // Enable action buttons + this.elements.copyBtn.disabled = data.messages.length === 0; + this.elements.exportBtn.disabled = data.messages.length === 0; + } catch (error) { + console.error('Timeline query failed:', error); + this.showError(error.message); + } + }, + + // Show loading state + showLoading() { + this.elements.output.innerHTML = '
Querying timeline...
'; + }, + + // Show error + showError(message) { + this.elements.output.innerHTML = ` +
+ Error: ${message} +
+ `; + }, + + // Update stats + updateStats(data) { + const fromStr = data.time_range.from ? new Date(data.time_range.from).toLocaleString() : 'beginning'; + const toStr = data.time_range.to ? new Date(data.time_range.to).toLocaleString() : 'now'; + + this.elements.stats.textContent = `${data.total_count} messages (${fromStr} โ†’ ${toStr})`; + }, + + // Render messages + renderMessages(data) { + if (data.messages.length === 0) { + this.elements.output.innerHTML = '
No messages found
'; + this.updateCharTokenCount(0, 0); + return; + } + + const messagesHtml = data.messages.map(msg => this.renderMessage(msg)).join(''); + this.elements.output.innerHTML = messagesHtml; + + // Calculate total chars and tokens + const totalChars = this.currentMessages.reduce((sum, msg) => sum + msg.content.length, 0); + const totalTokens = Math.ceil(totalChars / 4); // 4 chars โ‰ˆ 1 token + this.updateCharTokenCount(totalChars, totalTokens); + }, + + // Update char and token count display + updateCharTokenCount(chars, tokens) { + if (this.elements.charCount) { + this.elements.charCount.textContent = `${chars.toLocaleString()} chars`; + } + if (this.elements.tokenCount) { + this.elements.tokenCount.textContent = `~${tokens.toLocaleString()} tokens`; + } + }, + + // Render single message + renderMessage(msg) { + const timestamp = new Date(msg.timestamp); + const timeStr = this.formatTime(timestamp); + const providerIcon = this.getProviderIcon(msg.provider); + const projectStr = msg.project || 'None'; + + // Apply truncation based on display mode + const content = this.displayMode === 'full' + ? msg.content + : this.truncateContent(msg.content); + + const displayText = this.displayMode === 'full' ? content : content.text; + const isTruncated = this.displayMode === 'compact' && content.isTruncated; + + return ` +
+
+ ${timeStr} + [${msg.role.padEnd(9)}] + ${providerIcon} ${msg.provider} + (${projectStr}) + +
+
${this.escapeHtml(displayText)}
+ ${isTruncated ? `
[${content.hiddenChars} more chars]
` : ''} +
+ `; + }, + + // Format timestamp + formatTime(date) { + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + return `${month}-${day} ${hours}:${minutes}`; + }, + + // Get provider icon + getProviderIcon(provider) { + const icons = { + 'Claude': '๐ŸŸ ', + 'Gemini': '๐Ÿ”ต', + 'Cursor': '๐Ÿ”ท', + 'Codex': 'โšช', + }; + return icons[provider] || 'โšซ'; + }, + + // Truncate content (head 400 + tail 200) + truncateContent(content, headChars = 400, tailChars = 200) { + if (content.length <= headChars + tailChars) { + return { text: content, isTruncated: false, hiddenChars: 0 }; + } + + const head = content.substring(0, headChars); + const tail = content.substring(content.length - tailChars); + const hiddenChars = content.length - headChars - tailChars; + + return { + text: `${head}\n...\n${tail}`, + isTruncated: true, + hiddenChars: hiddenChars, + }; + }, + + // Escape HTML + escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + }, + + // Copy all messages + async copyAll() { + if (this.currentMessages.length === 0) return; + + const text = this.formatMessagesForCopy(this.currentMessages); + + try { + await navigator.clipboard.writeText(text); + this.showCopyFeedback(this.elements.copyBtn); + } catch (error) { + console.error('Copy failed:', error); + alert('Failed to copy to clipboard'); + } + }, + + // Format messages for copying + formatMessagesForCopy(messages) { + const since = this.elements.sinceInput.value || 'beginning'; + const until = this.elements.untilInput.value || 'now'; + + let text = '=== RetroChat Timeline Export ===\n'; + text += `Time Range: ${since} to ${until}\n`; + text += `Total: ${messages.length} messages\n\n`; + text += '---\n\n'; + + messages.forEach(msg => { + const timestamp = new Date(msg.timestamp); + const timeStr = this.formatTime(timestamp); + const projectStr = msg.project || 'None'; + + text += `${timeStr} [${msg.role}] ${msg.provider} (${projectStr})\n`; + text += `${msg.content}\n\n`; + }); + + return text; + }, + + // Show copy feedback + showCopyFeedback(button) { + const originalText = button.textContent; + button.textContent = 'โœ“ Copied!'; + button.disabled = true; + + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + }, 2000); + }, + + // Export as JSONL + exportJSONL() { + if (this.currentMessages.length === 0) return; + + const jsonl = this.currentMessages.map(msg => JSON.stringify(msg)).join('\n'); + const blob = new Blob([jsonl], { type: 'application/jsonl' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `timeline-export-${Date.now()}.jsonl`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.showCopyFeedback(this.elements.exportBtn); + }, +}; + +// Setup copy button for individual messages (using event delegation) +document.addEventListener('click', async (e) => { + if (e.target.classList.contains('timeline-copy-msg')) { + const messageId = e.target.dataset.messageId; + const message = Timeline.currentMessages.find(m => m.message_id === messageId); + + if (message) { + const timestamp = new Date(message.timestamp); + const text = `Time: ${timestamp.toISOString()}\nProvider: ${message.provider}\nProject: ${message.project || 'None'}\nRole: ${message.role}\n\n${message.content}`; + + try { + await navigator.clipboard.writeText(text); + Timeline.showCopyFeedback(e.target); + } catch (error) { + console.error('Copy failed:', error); + alert('Failed to copy to clipboard'); + } + } + } +}); From 6b1dea7e1eeafa8eaa63510556a3c0943c228d79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EB=B3=91=EC=9A=B1?= Date: Mon, 20 Oct 2025 01:32:46 +0900 Subject: [PATCH 6/6] feat: add tool message filtering for timeline compact mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Add TimeRangeQuery struct to MessageRepository for cleaner API - Filter tool_uses/tool_results in compact mode at SQL level - Remove limit input from web UI for better UX - Ensure consistent filtering logic between CLI and Web - Update web UI to pass format parameter to API ๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli/query.rs | 25 ++++++++++++-------- src/database/message_repo.rs | 46 +++++++++++++++++++++--------------- src/web/handlers/timeline.rs | 17 ++++++++----- src/web/static/index.html | 3 +-- src/web/static/style.css | 14 +++++++---- src/web/static/timeline.js | 8 +++---- 6 files changed, 67 insertions(+), 46 deletions(-) diff --git a/src/cli/query.rs b/src/cli/query.rs index 311e87f..8e6a432 100644 --- a/src/cli/query.rs +++ b/src/cli/query.rs @@ -180,17 +180,22 @@ pub async fn handle_timeline_command(options: TimelineOptions) -> Result<()> { let db_manager = DatabaseManager::new(&db_path).await?; let message_repo = crate::database::message_repo::MessageRepository::new(&db_manager); + // Determine if we should exclude tool messages (compact mode excludes them) + let exclude_tool_messages = options.format.as_str() == "compact"; + + // Build query + let query = crate::database::message_repo::TimeRangeQuery { + from, + to, + provider: options.provider, + role: options.role, + limit: options.limit.map(|l| l as i64), + reverse: options.reverse, + exclude_tool_messages, + }; + // Query messages - let messages = message_repo - .get_by_time_range( - from, - to, - options.provider.as_deref(), - options.role.as_deref(), - options.limit.map(|l| l as i64), - options.reverse, - ) - .await?; + let messages = message_repo.get_by_time_range(query).await?; // Format output match options.format.as_str() { diff --git a/src/database/message_repo.rs b/src/database/message_repo.rs index f3b887a..927d606 100644 --- a/src/database/message_repo.rs +++ b/src/database/message_repo.rs @@ -7,6 +7,18 @@ use uuid::Uuid; use super::connection::DatabaseManager; use crate::models::message::{Message, MessageRole}; +/// Query options for time range queries +#[derive(Debug, Clone, Default)] +pub struct TimeRangeQuery { + pub from: Option>, + pub to: Option>, + pub provider: Option, + pub role: Option, + pub limit: Option, + pub reverse: bool, + pub exclude_tool_messages: bool, +} + pub struct MessageRepository { pool: Pool, } @@ -229,15 +241,7 @@ impl MessageRepository { } /// Get messages by time range with optional filters - pub async fn get_by_time_range( - &self, - from: Option>, - to: Option>, - provider: Option<&str>, - role: Option<&str>, - limit: Option, - reverse: bool, - ) -> AnyhowResult> { + pub async fn get_by_time_range(&self, query: TimeRangeQuery) -> AnyhowResult> { let mut sql = String::from( r#" SELECT m.id, m.session_id, m.role, m.content, m.timestamp, @@ -249,15 +253,15 @@ impl MessageRepository { let mut conditions = Vec::new(); - if from.is_some() { + if query.from.is_some() { conditions.push("m.timestamp >= ?"); } - if to.is_some() { + if query.to.is_some() { conditions.push("m.timestamp <= ?"); } - if provider.is_some() { + if query.provider.is_some() { conditions.push( "EXISTS ( SELECT 1 FROM chat_sessions cs @@ -266,37 +270,41 @@ impl MessageRepository { ); } - if role.is_some() { + if query.role.is_some() { conditions.push("m.role = ?"); } + if query.exclude_tool_messages { + conditions.push("m.tool_uses IS NULL AND m.tool_results IS NULL"); + } + if !conditions.is_empty() { sql.push_str(" WHERE "); sql.push_str(&conditions.join(" AND ")); } sql.push_str(" ORDER BY m.timestamp "); - sql.push_str(if reverse { "DESC" } else { "ASC" }); + sql.push_str(if query.reverse { "DESC" } else { "ASC" }); - if let Some(lim) = limit { + if let Some(lim) = query.limit { sql.push_str(&format!(" LIMIT {}", lim)); } let mut query_builder = sqlx::query(&sql); - if let Some(from_time) = from { + if let Some(from_time) = query.from { query_builder = query_builder.bind(from_time.to_rfc3339()); } - if let Some(to_time) = to { + if let Some(to_time) = query.to { query_builder = query_builder.bind(to_time.to_rfc3339()); } - if let Some(prov) = provider { + if let Some(prov) = &query.provider { query_builder = query_builder.bind(prov); } - if let Some(r) = role { + if let Some(r) = &query.role { query_builder = query_builder.bind(r); } diff --git a/src/web/handlers/timeline.rs b/src/web/handlers/timeline.rs index c59cc1d..f73cded 100644 --- a/src/web/handlers/timeline.rs +++ b/src/web/handlers/timeline.rs @@ -15,8 +15,8 @@ pub struct TimelineQuery { pub until: Option, pub provider: Option, pub role: Option, - pub limit: Option, pub reverse: Option, + pub format: Option, } #[derive(Debug, Serialize, FromRow)] @@ -75,7 +75,11 @@ pub async fn query_timeline( .map_err(|e| AppError::Internal(format!("Failed to connect to database: {e}")))?, ); - // Build custom SQL query with JOIN to get provider and project + // Determine if we should exclude tool messages (compact mode excludes them) + let format = params.format.as_deref().unwrap_or("compact"); + let exclude_tool_messages = format == "compact"; + + // Build SQL query with JOIN (more efficient than N+1 queries) let mut sql = String::from( r#" SELECT @@ -109,6 +113,11 @@ pub async fn query_timeline( conditions.push("m.role = ?"); } + // Apply tool message filtering for compact mode (same logic as MessageRepository) + if exclude_tool_messages { + conditions.push("m.tool_uses IS NULL AND m.tool_results IS NULL"); + } + if !conditions.is_empty() { sql.push_str(" WHERE "); sql.push_str(&conditions.join(" AND ")); @@ -121,10 +130,6 @@ pub async fn query_timeline( "ASC" }); - if let Some(limit) = params.limit { - sql.push_str(&format!(" LIMIT {}", limit)); - } - let mut query = sqlx::query_as::<_, TimelineMessage>(&sql); if let Some(from_time) = from { diff --git a/src/web/static/index.html b/src/web/static/index.html index 88a77d9..a27e99f 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -99,10 +99,9 @@

RetroChat

-
diff --git a/src/web/static/style.css b/src/web/static/style.css index ab46d3f..9c8aed1 100644 --- a/src/web/static/style.css +++ b/src/web/static/style.css @@ -28,12 +28,16 @@ body { color: var(--text-primary); line-height: 1.5; min-height: 100vh; + overflow-y: auto; } .container { max-width: 1400px; margin: 0 auto; padding: 20px; + display: flex; + flex-direction: column; + min-height: 100vh; } /* Header */ @@ -209,7 +213,10 @@ body { border-radius: var(--radius); border: 1px solid var(--border); padding: 0; - min-height: 500px; + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; } /* All views should have same structure */ @@ -218,7 +225,8 @@ body { .timeline-view { display: flex; flex-direction: column; - min-height: 500px; + height: 100%; + min-height: 0; } .section-header { @@ -775,8 +783,6 @@ body { border: none; border-radius: 0; padding: 16px 20px; - min-height: 400px; - max-height: 70vh; overflow-y: auto; font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Consolas', monospace; font-size: 0.9rem; diff --git a/src/web/static/timeline.js b/src/web/static/timeline.js index 22cca95..7d1af8b 100644 --- a/src/web/static/timeline.js +++ b/src/web/static/timeline.js @@ -13,7 +13,6 @@ const Timeline = { providerSelect: null, roleSelect: null, modeSelect: null, - limitInput: null, queryBtn: null, copyBtn: null, exportBtn: null, @@ -31,7 +30,6 @@ const Timeline = { providerSelect: document.getElementById('timeline-provider'), roleSelect: document.getElementById('timeline-role'), modeSelect: document.getElementById('timeline-mode'), - limitInput: document.getElementById('timeline-limit'), queryBtn: document.getElementById('timeline-query-btn'), copyBtn: document.getElementById('timeline-copy-btn'), exportBtn: document.getElementById('timeline-export-btn'), @@ -70,7 +68,7 @@ const Timeline = { } // Enter key in inputs - [this.elements.sinceInput, this.elements.untilInput, this.elements.limitInput].forEach(input => { + [this.elements.sinceInput, this.elements.untilInput].forEach(input => { if (input) { input.addEventListener('keypress', (e) => { if (e.key === 'Enter') this.query(); @@ -98,13 +96,13 @@ const Timeline = { const until = this.elements.untilInput.value.trim(); const provider = this.elements.providerSelect.value; const role = this.elements.roleSelect.value; - const limit = this.elements.limitInput.value; + const format = this.elements.modeSelect.value; if (since) params.append('since', since); if (until) params.append('until', until); if (provider) params.append('provider', provider); if (role) params.append('role', role); - if (limit) params.append('limit', limit); + if (format) params.append('format', format); try { this.showLoading();