Skip to content

Commit ead7c46

Browse files
oratisclaude
andcommitted
feat(desktop): manual session rename (double-click in sidebar)
Completes the naming mechanism: auto-title (#104) + manual override. Double- click a session in the sidebar → inline input → Enter/blur saves, Esc cancels. - Rust session_set_title(id, title) writes the title onto the session_meta header line (prepends one for older sessions); empty clears it → falls back to the auto-derived title. - derive_session_title now prefers a manual meta title over the first-user- message derivation. - tauri-api sessionSetTitle; Sidebar inline-edit (stopPropagation so editing doesn't trigger select; reloads the list after save). +1 Rust test (derive prefers meta title, else first user msg). Rust 9 pass; desktop typecheck/lint/format clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c0719be commit ead7c46

4 files changed

Lines changed: 158 additions & 23 deletions

File tree

apps/desktop/src-tauri/src/commands.rs

Lines changed: 96 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -236,10 +236,12 @@ pub struct SessionMeta {
236236
pub title: String,
237237
}
238238

239-
/// First non-empty line of the first user message, truncated for display. Used
240-
/// as a session's auto-title in the sidebar (no manual naming required).
239+
/// A session's display title. Prefers a manual title set on the `session_meta`
240+
/// header line (via session_set_title); otherwise derives one from the first
241+
/// user message (first non-empty line, truncated). Returns None if neither.
241242
fn derive_session_title(path: &std::path::Path) -> Option<String> {
242243
let text = std::fs::read_to_string(path).ok()?;
244+
let mut from_user: Option<String> = None;
243245
for line in text.lines() {
244246
let line = line.trim();
245247
if line.is_empty() {
@@ -248,25 +250,71 @@ fn derive_session_title(path: &std::path::Path) -> Option<String> {
248250
let Ok(v) = serde_json::from_str::<serde_json::Value>(line) else {
249251
continue;
250252
};
251-
if v.get("type").and_then(|t| t.as_str()) != Some("message") {
252-
continue;
253-
}
254-
if v.get("role").and_then(|r| r.as_str()) != Some("user") {
255-
continue;
256-
}
257-
let content = v.get("content").and_then(|c| c.as_array())?;
258-
for block in content {
259-
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
260-
if let Some(txt) = block.get("text").and_then(|t| t.as_str()) {
261-
let title = clean_title(txt);
262-
if !title.is_empty() {
263-
return Some(title);
253+
match v.get("type").and_then(|t| t.as_str()) {
254+
// Manual title wins — return immediately.
255+
Some("session_meta") => {
256+
if let Some(t) = v.get("title").and_then(|t| t.as_str()) {
257+
let t = t.trim();
258+
if !t.is_empty() {
259+
return Some(clean_title(t));
264260
}
265261
}
266262
}
263+
Some("message") if v.get("role").and_then(|r| r.as_str()) == Some("user") => {
264+
if from_user.is_none() {
265+
if let Some(content) = v.get("content").and_then(|c| c.as_array()) {
266+
for block in content {
267+
if block.get("type").and_then(|t| t.as_str()) == Some("text") {
268+
if let Some(txt) = block.get("text").and_then(|t| t.as_str()) {
269+
let title = clean_title(txt);
270+
if !title.is_empty() {
271+
from_user = Some(title);
272+
break;
273+
}
274+
}
275+
}
276+
}
277+
}
278+
}
279+
}
280+
_ => {}
267281
}
268282
}
269-
None
283+
from_user
284+
}
285+
286+
/// Set (or clear, with "") a session's manual title on its session_meta header.
287+
#[tauri::command]
288+
pub fn session_set_title(id: String, title: String) -> Result<(), String> {
289+
let Some(home) = dirs::home_dir() else {
290+
return Err("no home directory".into());
291+
};
292+
let path = home
293+
.join(".deepcode")
294+
.join("sessions")
295+
.join(format!("{id}.jsonl"));
296+
let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?;
297+
let trimmed = title.trim();
298+
let mut lines: Vec<String> = text.lines().map(|l| l.to_string()).collect();
299+
let mut updated = false;
300+
for line in lines.iter_mut() {
301+
let Ok(mut v) = serde_json::from_str::<serde_json::Value>(line.trim()) else {
302+
continue;
303+
};
304+
if v.get("type").and_then(|t| t.as_str()) == Some("session_meta") {
305+
v["title"] = serde_json::Value::String(trimmed.to_string());
306+
*line = v.to_string();
307+
updated = true;
308+
break;
309+
}
310+
}
311+
if !updated {
312+
// No meta header (older session) — prepend one carrying the title.
313+
let meta = serde_json::json!({ "type": "session_meta", "id": id, "title": trimmed });
314+
lines.insert(0, meta.to_string());
315+
}
316+
std::fs::write(&path, lines.join("\n") + "\n")
317+
.map_err(|e| format!("write {}: {}", path.display(), e))
270318
}
271319

272320
/// Strip a leading <system-reminder>…</system-reminder> block (CLI-created
@@ -409,4 +457,36 @@ mod contract_tests {
409457
assert_eq!(t.chars().count(), 49); // 48 + '…'
410458
assert!(t.ends_with('…'));
411459
}
460+
461+
#[test]
462+
fn derive_title_prefers_meta_then_first_user() {
463+
use std::io::Write;
464+
let dir = std::env::temp_dir();
465+
let pid = std::process::id();
466+
467+
// A manual title on the meta header wins over the first user message.
468+
let p1 = dir.join(format!("dc-title-{pid}-a.jsonl"));
469+
let mut f = std::fs::File::create(&p1).unwrap();
470+
writeln!(f, r#"{{"type":"session_meta","id":"x","title":"My Custom Name"}}"#).unwrap();
471+
writeln!(
472+
f,
473+
r#"{{"type":"message","role":"user","content":[{{"type":"text","text":"the prompt"}}]}}"#
474+
)
475+
.unwrap();
476+
assert_eq!(derive_session_title(&p1).as_deref(), Some("My Custom Name"));
477+
478+
// No meta title → derive from the first user message (CJK).
479+
let p2 = dir.join(format!("dc-title-{pid}-b.jsonl"));
480+
let mut f2 = std::fs::File::create(&p2).unwrap();
481+
writeln!(f2, r#"{{"type":"session_meta","id":"y"}}"#).unwrap();
482+
writeln!(
483+
f2,
484+
r#"{{"type":"message","role":"user","content":[{{"type":"text","text":"做一个游戏"}}]}}"#
485+
)
486+
.unwrap();
487+
assert_eq!(derive_session_title(&p2).as_deref(), Some("做一个游戏"));
488+
489+
std::fs::remove_file(&p1).ok();
490+
std::fs::remove_file(&p2).ok();
491+
}
412492
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use commands::{
1818
append_allow_matcher, cli_path, get_app_info, get_settings_path, list_sessions,
1919
load_keybindings, load_settings_file, open_url, read_credentials, save_credentials,
2020
save_keybindings, save_settings_file, session_append, session_create, session_read,
21+
session_set_title,
2122
};
2223
use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write};
2324
use tauri::Manager;
@@ -44,6 +45,7 @@ pub fn run() {
4445
session_create,
4546
session_append,
4647
session_read,
48+
session_set_title,
4749
list_sessions,
4850
cli_path,
4951
open_url,

apps/desktop/src/components/Sidebar.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
// folder + a small switch-folder button. Below: sessions bucketed by
55
// Today/Yesterday/Earlier per the spec note ①.
66

7-
import { useEffect, useState } from 'react';
7+
import { useCallback, useEffect, useState } from 'react';
88
import { projectName } from '../lib/project.js';
9-
import { listSessions, type SessionMeta } from '../lib/tauri-api.js';
9+
import { listSessions, sessionSetTitle, type SessionMeta } from '../lib/tauri-api.js';
1010
import { BrandMark } from './BrandMark.js';
1111

1212
interface SidebarProps {
@@ -46,14 +46,31 @@ export function Sidebar({
4646
}: SidebarProps): JSX.Element {
4747
const [sessions, setSessions] = useState<SessionMeta[]>([]);
4848
const [now, setNow] = useState<number>(Math.floor(Date.now() / 1000));
49+
// Inline rename: which session is being edited + its draft title.
50+
const [editingId, setEditingId] = useState<string | null>(null);
51+
const [editValue, setEditValue] = useState('');
4952

50-
useEffect(() => {
53+
const reload = useCallback(() => {
5154
void listSessions()
5255
.then(setSessions)
5356
.catch(() => setSessions([]));
57+
}, []);
58+
59+
useEffect(() => {
60+
reload();
5461
const t = setInterval(() => setNow(Math.floor(Date.now() / 1000)), 30_000);
5562
return () => clearInterval(t);
56-
}, []);
63+
}, [reload]);
64+
65+
async function commitRename(id: string): Promise<void> {
66+
setEditingId(null);
67+
try {
68+
await sessionSetTitle(id, editValue.trim());
69+
reload();
70+
} catch {
71+
/* keep the old title on failure */
72+
}
73+
}
5774

5875
const grouped: Record<Bucket, SessionMeta[]> = {
5976
Today: [],
@@ -150,11 +167,42 @@ export function Sidebar({
150167
<div
151168
key={s.id}
152169
className={'item' + (s.id === activeSessionId ? ' active' : '')}
153-
onClick={() => onPickSession(s.id)}
154-
title={`${s.title} · ${s.id}`}
170+
onClick={() => editingId !== s.id && onPickSession(s.id)}
171+
onDoubleClick={(e) => {
172+
e.stopPropagation();
173+
setEditingId(s.id);
174+
setEditValue(s.title?.trim() ? s.title : '');
175+
}}
176+
title={`${s.title} · ${s.id} (double-click to rename)`}
155177
>
156178
<span className="dot" />
157-
<span className="label">{s.title?.trim() ? s.title : shortTitle(s.id)}</span>
179+
{editingId === s.id ? (
180+
<input
181+
className="label"
182+
autoFocus
183+
value={editValue}
184+
placeholder="Session name…"
185+
onChange={(e) => setEditValue(e.target.value)}
186+
onClick={(e) => e.stopPropagation()}
187+
onKeyDown={(e) => {
188+
if (e.key === 'Enter') void commitRename(s.id);
189+
else if (e.key === 'Escape') setEditingId(null);
190+
}}
191+
onBlur={() => void commitRename(s.id)}
192+
style={{
193+
flex: 1,
194+
minWidth: 0,
195+
background: 'transparent',
196+
border: '1px solid var(--line)',
197+
borderRadius: 4,
198+
color: 'var(--text-0)',
199+
font: 'inherit',
200+
padding: '0 4px',
201+
}}
202+
/>
203+
) : (
204+
<span className="label">{s.title?.trim() ? s.title : shortTitle(s.id)}</span>
205+
)}
158206
<span className="meta">{relTime(s.updated_at_secs, now)}</span>
159207
</div>
160208
))}

apps/desktop/src/lib/tauri-api.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,11 @@ export async function sessionCreate(cwd: string): Promise<string> {
132132
return (await invoke('session_create', { cwd })) as string;
133133
}
134134

135+
/** Set (or clear, with '') a session's manual title. */
136+
export async function sessionSetTitle(id: string, title: string): Promise<void> {
137+
await invoke('session_set_title', { id, title });
138+
}
139+
135140
/** Append one JSON message line to a session's JSONL file. */
136141
export async function sessionAppend(id: string, message: Record<string, unknown>): Promise<void> {
137142
await invoke('session_append', { id, message });

0 commit comments

Comments
 (0)