Skip to content

Commit d5d34a4

Browse files
MarkShawn2020claude
andcommitted
feat(detail): conversation transcript loader on every surface
Phase 4.5. Core (lovcode-core::detail): - get_conversation(id) — TermQuery on the id field, read source + raw_path from the stored doc, route to the owning adapter, parse the file fresh and return the conversation whose id matches. Surfaces wired: - CLI: `lovcode show <id> [--json]` - HTTP: `GET /conversation/{id}` - MCP: new `get_conversation` tool - Tauri: `get_conversation` command + `focus_main_window` (palette can hide itself, focus main, and emit a navigate event with the id) Frontend: - New /conversation/:id route (pages/conversation/[id].tsx) — renders the message list with role / timestamp / pre-wrapped content. - ResultCard is now clickable: main → react-router navigate, palette → invoke focus_main_window so the main window navigates instead. - main.tsx listens for `lovcode:navigate-conversation` events from the Tauri side and routes the hash router accordingly. Smoke-tested: `lovcode show <id>` prints the full transcript of the in-progress refactor session correctly, including CJK content. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 219d389 commit d5d34a4

12 files changed

Lines changed: 274 additions & 8 deletions

File tree

crates/lovcode-cli/src/cmd_serve.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
//! HTTP server — axum. Used by the web UI and external clients.
22
33
use anyhow::Result;
4-
use axum::extract::{Query, State};
4+
use axum::extract::{Path as AxumPath, Query, State};
55
use axum::http::StatusCode;
66
use axum::response::Json;
77
use axum::routing::get;
88
use axum::Router;
99
use clap::Args as ClapArgs;
1010
use lovcode_core::adapter::builtin_adapters;
11+
use lovcode_core::detail;
1112
use lovcode_core::index::LovcodeIndex;
1213
use lovcode_core::query;
13-
use lovcode_core::types::{SearchQuery, SearchResult};
14+
use lovcode_core::types::{Conversation, SearchQuery, SearchResult};
1415
use serde::Deserialize;
1516
use std::net::{IpAddr, SocketAddr};
1617
use std::path::Path;
@@ -48,6 +49,7 @@ async fn serve(index_dir: &Path, args: Args) -> Result<()> {
4849
.route("/health", get(|| async { "ok" }))
4950
.route("/sources", get(list_sources))
5051
.route("/search", get(do_search))
52+
.route("/conversation/{id}", get(get_conversation))
5153
.with_state(state);
5254

5355
if args.cors {
@@ -88,6 +90,15 @@ async fn do_search(
8890
Ok(Json(r))
8991
}
9092

93+
async fn get_conversation(
94+
State(s): State<AppState>,
95+
AxumPath(id): AxumPath<String>,
96+
) -> Result<Json<Conversation>, (StatusCode, String)> {
97+
detail::get_conversation(&s.index, &id)
98+
.map(Json)
99+
.map_err(|e| (StatusCode::NOT_FOUND, e.to_string()))
100+
}
101+
91102
async fn list_sources() -> Json<Vec<serde_json::Value>> {
92103
let out = builtin_adapters()
93104
.iter()

crates/lovcode-cli/src/cmd_show.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
use anyhow::Result;
2+
use clap::Args as ClapArgs;
3+
use lovcode_core::detail;
4+
use lovcode_core::index::LovcodeIndex;
5+
use std::path::Path;
6+
7+
#[derive(ClapArgs)]
8+
pub struct Args {
9+
pub id: String,
10+
#[arg(long)]
11+
pub json: bool,
12+
}
13+
14+
pub fn run(index_dir: &Path, args: Args) -> Result<()> {
15+
let idx = LovcodeIndex::open_or_create(index_dir)?;
16+
let conv = detail::get_conversation(&idx, &args.id)?;
17+
if args.json {
18+
println!("{}", serde_json::to_string_pretty(&conv)?);
19+
return Ok(());
20+
}
21+
println!("# {}", conv.title.as_deref().unwrap_or(&conv.id));
22+
println!("source: {} id: {}", conv.source, conv.id);
23+
if let Some(p) = &conv.project { println!("project: {p}"); }
24+
println!();
25+
for (i, m) in conv.messages.iter().enumerate() {
26+
let role = format!("{:?}", m.role).to_lowercase();
27+
let ts = m.timestamp.map(|t| t.format("%H:%M:%S").to_string()).unwrap_or_default();
28+
println!("─── [{i}] {role} {ts} ───");
29+
println!("{}", m.content);
30+
println!();
31+
}
32+
Ok(())
33+
}

crates/lovcode-cli/src/main.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod cmd_index;
44
mod cmd_mcp;
55
mod cmd_search;
66
mod cmd_serve;
7+
mod cmd_show;
78
mod cmd_sources;
89

910
use clap::{Parser, Subcommand};
@@ -28,6 +29,9 @@ enum Cmd {
2829
/// Search the index.
2930
Search(cmd_search::Args),
3031

32+
/// Show one conversation by id.
33+
Show(cmd_show::Args),
34+
3135
/// List configured source adapters.
3236
Sources,
3337

@@ -55,6 +59,7 @@ fn main() -> anyhow::Result<()> {
5559
match cli.command {
5660
Cmd::Index(a) => cmd_index::run(&index_dir, a),
5761
Cmd::Search(a) => cmd_search::run(&index_dir, a),
62+
Cmd::Show(a) => cmd_show::run(&index_dir, a),
5863
Cmd::Sources => cmd_sources::run(&index_dir),
5964
Cmd::Serve(a) => cmd_serve::run(&index_dir, a),
6065
Cmd::Mcp => cmd_mcp::run(&index_dir),

crates/lovcode-core/src/detail.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
//! Conversation detail loader — re-parse from raw file using the id stored in the index.
2+
3+
use crate::adapter::builtin_adapters;
4+
use crate::index::LovcodeIndex;
5+
use crate::types::Conversation;
6+
use anyhow::{anyhow, Result};
7+
use std::path::PathBuf;
8+
use tantivy::query::TermQuery;
9+
use tantivy::schema::{IndexRecordOption, Value};
10+
use tantivy::TantivyDocument;
11+
12+
pub fn get_conversation(idx: &LovcodeIndex, id: &str) -> Result<Conversation> {
13+
let reader = idx.reader()?;
14+
let searcher = reader.searcher();
15+
let schema = idx.schema();
16+
17+
let term = tantivy::Term::from_field_text(schema.id, id);
18+
let query = TermQuery::new(term, IndexRecordOption::Basic);
19+
let hits = searcher.search(&query, &tantivy::collector::TopDocs::with_limit(1))?;
20+
let (_, addr) = hits.first().ok_or_else(|| anyhow!("not found: {id}"))?;
21+
let doc: TantivyDocument = searcher.doc(*addr)?;
22+
23+
let source = doc
24+
.get_first(schema.source)
25+
.and_then(|v| v.as_str())
26+
.ok_or_else(|| anyhow!("missing source field"))?
27+
.to_string();
28+
let raw_path: Option<PathBuf> = doc
29+
.get_first(schema.raw_path)
30+
.and_then(|v| v.as_str())
31+
.map(PathBuf::from);
32+
33+
let Some(path) = raw_path else {
34+
return Err(anyhow!("conversation has no raw_path (transient?): {id}"));
35+
};
36+
37+
let adapter = builtin_adapters()
38+
.into_iter()
39+
.find(|a| a.id() == source)
40+
.ok_or_else(|| anyhow!("unknown source adapter: {source}"))?;
41+
42+
let conversations = adapter.parse_many(&path)?;
43+
conversations
44+
.into_iter()
45+
.find(|c| c.id == id)
46+
.ok_or_else(|| anyhow!("conversation {id} not found in file {}", path.display()))
47+
}

crates/lovcode-core/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,4 @@ pub mod adapter;
1414
pub mod index;
1515
pub mod query;
1616
pub mod watcher;
17+
pub mod detail;

crates/lovcode-mcp/src/lib.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
//! server (e.g. `lovcode mcp`).
99
1010
use lovcode_core::adapter::builtin_adapters;
11+
use lovcode_core::detail;
1112
use lovcode_core::index::LovcodeIndex;
1213
use lovcode_core::query;
1314
use lovcode_core::types::SearchQuery;
@@ -28,6 +29,12 @@ pub struct LovcodeMcp {
2829
tool_router: ToolRouter<LovcodeMcp>,
2930
}
3031

32+
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
33+
pub struct GetArgs {
34+
/// Conversation id returned by `search_conversations`.
35+
pub id: String,
36+
}
37+
3138
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
3239
pub struct SearchArgs {
3340
/// Search query string. Supports phrases.
@@ -76,6 +83,18 @@ impl LovcodeMcp {
7683
Ok(CallToolResult::success(vec![Content::text(json)]))
7784
}
7885

86+
#[tool(description = "Fetch the full message transcript of one conversation by id.")]
87+
async fn get_conversation(
88+
&self,
89+
Parameters(args): Parameters<GetArgs>,
90+
) -> Result<CallToolResult, McpError> {
91+
let conv = detail::get_conversation(&self.index, &args.id)
92+
.map_err(|e| McpError::internal_error(format!("get failed: {e}"), None))?;
93+
let json = serde_json::to_string_pretty(&conv)
94+
.map_err(|e| McpError::internal_error(format!("serialize failed: {e}"), None))?;
95+
Ok(CallToolResult::success(vec![Content::text(json)]))
96+
}
97+
7998
#[tool(description = "List configured conversation source adapters and their discovered counts.")]
8099
async fn list_sources(&self) -> Result<CallToolResult, McpError> {
81100
let out: Vec<_> = builtin_adapters()

src-tauri/src/commands.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
//! Tauri command wrappers — thin adapters over `lovcode-core`.
22
33
use lovcode_core::adapter::builtin_adapters;
4+
use lovcode_core::detail;
45
use lovcode_core::index::{default_index_dir, LovcodeIndex};
56
use lovcode_core::query;
6-
use lovcode_core::types::{SearchQuery, SearchResult};
7+
use lovcode_core::types::{Conversation, SearchQuery, SearchResult};
78
use lovcode_core::watcher;
89
use serde::Serialize;
910
use std::sync::OnceLock;
@@ -60,6 +61,11 @@ pub fn search(
6061
query::search(index(), &query).map_err(|e| e.to_string())
6162
}
6263

64+
#[tauri::command]
65+
pub fn get_conversation(id: String) -> Result<Conversation, String> {
66+
detail::get_conversation(index(), &id).map_err(|e| e.to_string())
67+
}
68+
6369
#[tauri::command]
6470
pub async fn rebuild_index() -> Result<usize, String> {
6571
tokio::task::spawn_blocking(|| {

src-tauri/src/lib.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
mod commands;
44

5-
use tauri::Manager;
5+
use tauri::{Emitter, Manager};
66
use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut, ShortcutState};
77

88
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -36,8 +36,10 @@ pub fn run() {
3636
commands::ping,
3737
commands::list_sources,
3838
commands::search,
39+
commands::get_conversation,
3940
commands::rebuild_index,
4041
toggle_search_overlay,
42+
focus_main_window,
4143
])
4244
.run(tauri::generate_context!())
4345
.expect("error while running tauri application");
@@ -59,3 +61,18 @@ fn toggle_search_window(app: &tauri::AppHandle) {
5961
fn toggle_search_overlay(app: tauri::AppHandle) {
6062
toggle_search_window(&app);
6163
}
64+
65+
/// Focus the main window and emit a navigation event with the target conversation id.
66+
#[tauri::command]
67+
fn focus_main_window(app: tauri::AppHandle, conversation_id: Option<String>) {
68+
if let Some(win) = app.get_webview_window("main") {
69+
let _ = win.show();
70+
let _ = win.set_focus();
71+
if let Some(id) = conversation_id {
72+
let _ = win.emit("lovcode:navigate-conversation", id);
73+
}
74+
}
75+
if let Some(win) = app.get_webview_window("search") {
76+
let _ = win.hide();
77+
}
78+
}

src/components/SearchUI.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useEffect, useMemo, useRef, useState } from "react";
2-
import { listSources, rebuildIndex, search, type SearchResult, type SourceSummary } from "@/lib/api";
2+
import { focusMainWindow, listSources, rebuildIndex, search, type SearchResult, type SourceSummary } from "@/lib/api";
3+
import { useNavigate } from "react-router-dom";
34

45
interface Props {
56
/** Compact = floating palette (no header, no rebuild). */
67
compact?: boolean;
78
}
89

910
export function SearchUI({ compact = false }: Props) {
11+
const navigate = useNavigate();
1012
const [q, setQ] = useState("");
1113
const [source, setSource] = useState<string>("");
1214
const [sources, setSources] = useState<SourceSummary[]>([]);
@@ -124,7 +126,18 @@ export function SearchUI({ compact = false }: Props) {
124126
)}
125127
<ul className="space-y-3">
126128
{results.map((r) => (
127-
<ResultCard key={r.conversation_id + r.source} r={r} compact={compact} />
129+
<ResultCard
130+
key={r.conversation_id + r.source}
131+
r={r}
132+
compact={compact}
133+
onSelect={() => {
134+
if (compact) {
135+
focusMainWindow(r.conversation_id).catch(() => {});
136+
} else {
137+
navigate(`/conversation/${encodeURIComponent(r.conversation_id)}`);
138+
}
139+
}}
140+
/>
128141
))}
129142
</ul>
130143
</div>
@@ -133,10 +146,16 @@ export function SearchUI({ compact = false }: Props) {
133146
);
134147
}
135148

136-
function ResultCard({ r, compact }: { r: SearchResult; compact: boolean }) {
149+
function ResultCard({ r, compact, onSelect }: { r: SearchResult; compact: boolean; onSelect: () => void }) {
137150
const date = r.timestamp ? new Date(r.timestamp).toISOString().slice(0, 10) : "—";
138151
return (
139-
<li className={`rounded-xl border border-border bg-card ${compact ? "p-3" : "p-4"}`}>
152+
<li
153+
role="button"
154+
tabIndex={0}
155+
onClick={onSelect}
156+
onKeyDown={(e) => { if (e.key === "Enter") onSelect(); }}
157+
className={`cursor-pointer rounded-xl border border-border bg-card transition hover:border-primary/40 hover:bg-muted/50 ${compact ? "p-3" : "p-4"}`}
158+
>
140159
<div className="flex items-baseline gap-3 text-xs text-muted-foreground">
141160
<span className="rounded bg-muted px-1.5 py-0.5 font-mono">{r.source}</span>
142161
<span>{date}</span>

src/lib/api.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,30 @@ export function search(args: {
3535
export function rebuildIndex(): Promise<number> {
3636
return invoke<number>("rebuild_index");
3737
}
38+
39+
export type ConversationRole = "user" | "assistant" | "system" | "tool" | "other";
40+
41+
export interface ConversationMessage {
42+
role: ConversationRole;
43+
content: string;
44+
timestamp: string | null;
45+
}
46+
47+
export interface Conversation {
48+
id: string;
49+
source: string;
50+
project: string | null;
51+
title: string | null;
52+
created_at: string | null;
53+
updated_at: string | null;
54+
messages: ConversationMessage[];
55+
raw_path: string | null;
56+
}
57+
58+
export function getConversation(id: string): Promise<Conversation> {
59+
return invoke<Conversation>("get_conversation", { id });
60+
}
61+
62+
export function focusMainWindow(conversationId?: string): Promise<void> {
63+
return invoke("focus_main_window", { conversationId: conversationId ?? null });
64+
}

0 commit comments

Comments
 (0)