update: tui -rの際のリストの時系列ソート
This commit is contained in:
parent
942ab0e15b
commit
ccc6efc0e6
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3653,6 +3653,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"session-store",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tools",
|
||||
|
|
|
|||
|
|
@ -22,4 +22,5 @@ pulldown-cmark = { version = "0.13.3", default-features = false }
|
|||
llm-worker.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = { workspace = true }
|
||||
tools = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -293,11 +293,11 @@ async fn run_resume() -> Result<(), Box<dyn std::error::Error>> {
|
|||
// Phase 1: pick a session in its own inline viewport, dropping the
|
||||
// viewport before the name dialog opens so each phase gets fresh
|
||||
// vertical room.
|
||||
let leaf_segment_id = match picker::run().await? {
|
||||
let segment_id = match picker::run().await? {
|
||||
PickerOutcome::Picked { segment_id } => segment_id,
|
||||
PickerOutcome::Cancelled => return Ok(()),
|
||||
};
|
||||
run_spawn(Some(leaf_segment_id)).await
|
||||
run_spawn(Some(segment_id)).await
|
||||
}
|
||||
|
||||
async fn run_spawn(resume_from: Option<SegmentId>) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
//! Inline-viewport "pick a session to restore" UX.
|
||||
//!
|
||||
//! Reads the most recent sessions from the configured store, lets the
|
||||
//! user pick one with the arrow keys, and returns the chosen
|
||||
//! Reads the most recently updated sessions from the configured store,
|
||||
//! lets the user pick one with the arrow keys, and returns the chosen
|
||||
//! `SegmentId`. Closes its inline viewport before returning so the
|
||||
//! caller can open a fresh viewport for the name dialog.
|
||||
//!
|
||||
|
|
@ -62,27 +62,31 @@ impl From<session_store::StoreError> for PickerError {
|
|||
}
|
||||
|
||||
pub enum PickerOutcome {
|
||||
/// User picked a session; resume at its leaf segment. The pod-cli
|
||||
/// rehydrates `session_id` via `Store::lookup_session_of` so we only
|
||||
/// need to surface the segment here.
|
||||
/// User picked a session; resume at the segment represented by the
|
||||
/// selected row. The pod-cli rehydrates `session_id` via
|
||||
/// `Store::lookup_session_of` so we only need to surface the segment
|
||||
/// here.
|
||||
Picked {
|
||||
segment_id: SegmentId,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// One row in the picker view. Rendered from the leaf segment of a
|
||||
/// Session so the user can recognise their conversation at a glance
|
||||
/// without parsing UUIDs.
|
||||
/// One row in the picker view. Rendered from the most recently updated
|
||||
/// segment of a Session so the user can recognise their conversation at a
|
||||
/// glance without parsing UUIDs.
|
||||
struct Row {
|
||||
session_id: SessionId,
|
||||
leaf_segment_id: SegmentId,
|
||||
segment_id: SegmentId,
|
||||
/// Latest log-entry timestamp in the row's selected segment. Used only
|
||||
/// to order the picker newest-update first.
|
||||
updated_at: u64,
|
||||
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
|
||||
preview: String,
|
||||
/// `Some(pod_name)` when a live Pod currently holds an allocation
|
||||
/// for this session's leaf segment in `pods.json`. Picking such a
|
||||
/// row launches `pod --session <UUID>` which will fail with
|
||||
/// `SegmentConflict` — the badge warns the user up-front.
|
||||
/// for this row's segment in `pods.json`. Picking such a row launches
|
||||
/// `pod --session <UUID>` which will fail with `SegmentConflict` — the
|
||||
/// badge warns the user up-front.
|
||||
live_pod: Option<String>,
|
||||
}
|
||||
|
||||
|
|
@ -92,25 +96,9 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
|||
if sessions.is_empty() {
|
||||
return Err(PickerError::NoSessions);
|
||||
}
|
||||
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
|
||||
for session_id in sessions.into_iter().take(MAX_ROWS) {
|
||||
let Some(leaf_segment_id) = store.list_segments(session_id)?.into_iter().next() else {
|
||||
continue;
|
||||
};
|
||||
let preview = build_preview(&store, session_id, leaf_segment_id);
|
||||
// Best-effort live check. A pods.json I/O hiccup downgrades
|
||||
// the row to "no badge" rather than killing the picker — the
|
||||
// user still gets to see the listing.
|
||||
let live_pod = lookup_segment(leaf_segment_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|info| info.pod_name);
|
||||
rows.push(Row {
|
||||
session_id,
|
||||
leaf_segment_id,
|
||||
preview,
|
||||
live_pod,
|
||||
});
|
||||
let rows = build_rows(&store, sessions)?;
|
||||
if rows.is_empty() {
|
||||
return Err(PickerError::NoSessions);
|
||||
}
|
||||
|
||||
let mut selected = 0usize;
|
||||
|
|
@ -132,7 +120,7 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
|||
Some(Action::Submit) => {
|
||||
close_viewport(&mut terminal)?;
|
||||
return Ok(PickerOutcome::Picked {
|
||||
segment_id: rows[selected].leaf_segment_id,
|
||||
segment_id: rows[selected].segment_id,
|
||||
});
|
||||
}
|
||||
Some(Action::Cancel) => {
|
||||
|
|
@ -176,10 +164,86 @@ fn open_default_store() -> Result<FsStore, PickerError> {
|
|||
Ok(FsStore::new(&dir)?)
|
||||
}
|
||||
|
||||
fn build_preview(store: &FsStore, session_id: SessionId, segment_id: SegmentId) -> String {
|
||||
fn build_rows(store: &FsStore, sessions: Vec<SessionId>) -> Result<Vec<Row>, PickerError> {
|
||||
let mut rows = Vec::new();
|
||||
for session_id in sessions {
|
||||
let mut selected_segment: Option<(SegmentId, u64, String)> = None;
|
||||
for segment_id in store.list_segments(session_id)? {
|
||||
let (updated_at, preview) = summarize_segment(store, session_id, segment_id);
|
||||
if selected_segment
|
||||
.as_ref()
|
||||
.is_none_or(|(best_segment_id, best_updated_at, _)| {
|
||||
updated_at > *best_updated_at
|
||||
|| (updated_at == *best_updated_at && segment_id > *best_segment_id)
|
||||
})
|
||||
{
|
||||
selected_segment = Some((segment_id, updated_at, preview));
|
||||
}
|
||||
}
|
||||
|
||||
let Some((segment_id, updated_at, preview)) = selected_segment else {
|
||||
continue;
|
||||
};
|
||||
rows.push(Row {
|
||||
session_id,
|
||||
segment_id,
|
||||
updated_at,
|
||||
preview,
|
||||
live_pod: None,
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort_by(|a, b| {
|
||||
b.updated_at
|
||||
.cmp(&a.updated_at)
|
||||
.then_with(|| b.segment_id.cmp(&a.segment_id))
|
||||
.then_with(|| b.session_id.cmp(&a.session_id))
|
||||
});
|
||||
rows.truncate(MAX_ROWS);
|
||||
for row in &mut rows {
|
||||
// Best-effort live check. A pods.json I/O hiccup downgrades
|
||||
// the row to "no badge" rather than killing the picker — the
|
||||
// user still gets to see the listing.
|
||||
row.live_pod = lookup_segment(row.segment_id)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|info| info.pod_name);
|
||||
}
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn summarize_segment(
|
||||
store: &FsStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
) -> (u64, String) {
|
||||
match store.read_all(session_id, segment_id) {
|
||||
Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
|
||||
Err(_) => "[corrupt]".to_string(),
|
||||
Ok(entries) => (
|
||||
last_entry_ts(&entries).unwrap_or(0),
|
||||
last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
|
||||
),
|
||||
Err(_) => (0, "[corrupt]".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn last_entry_ts(entries: &[LogEntry]) -> Option<u64> {
|
||||
entries.iter().map(log_entry_ts).max()
|
||||
}
|
||||
|
||||
fn log_entry_ts(entry: &LogEntry) -> u64 {
|
||||
match entry {
|
||||
LogEntry::SegmentStart { ts, .. }
|
||||
| LogEntry::Invoke { ts, .. }
|
||||
| LogEntry::UserInput { ts, .. }
|
||||
| LogEntry::AssistantItem { ts, .. }
|
||||
| LogEntry::ToolResult { ts, .. }
|
||||
| LogEntry::SystemItem { ts, .. }
|
||||
| LogEntry::TurnEnd { ts, .. }
|
||||
| LogEntry::RunCompleted { ts, .. }
|
||||
| LogEntry::RunErrored { ts, .. }
|
||||
| LogEntry::ConfigChanged { ts, .. }
|
||||
| LogEntry::LlmUsage { ts, .. }
|
||||
| LogEntry::Extension { ts, .. } => *ts,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -335,3 +399,96 @@ fn short_segment(id: SessionId) -> String {
|
|||
let s = id.to_string();
|
||||
s.chars().take(8).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use llm_worker::llm_client::types::RequestConfig;
|
||||
use session_store::{new_segment_id, new_session_id};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn rows_are_sorted_by_latest_log_entry_timestamp() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let earlier_session = new_session_id();
|
||||
let later_session = new_session_id();
|
||||
let earlier_segment = new_segment_id();
|
||||
let later_segment = new_segment_id();
|
||||
|
||||
append_start(&store, earlier_session, earlier_segment, 10);
|
||||
append_start(&store, later_session, later_segment, 20);
|
||||
append_user(
|
||||
&store,
|
||||
earlier_session,
|
||||
earlier_segment,
|
||||
100,
|
||||
"latest update",
|
||||
);
|
||||
|
||||
let rows = build_rows(&store, store.list_sessions().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(rows[0].session_id, earlier_session);
|
||||
assert_eq!(rows[0].segment_id, earlier_segment);
|
||||
assert_eq!(rows[0].updated_at, 100);
|
||||
assert_eq!(rows[0].preview, "user: latest update");
|
||||
assert_eq!(rows[1].session_id, later_session);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_uses_the_most_recently_updated_segment_in_a_session() {
|
||||
let dir = tempdir().unwrap();
|
||||
let store = FsStore::new(dir.path()).unwrap();
|
||||
let session_id = new_session_id();
|
||||
let old_segment = new_segment_id();
|
||||
let new_segment = new_segment_id();
|
||||
|
||||
append_start(&store, session_id, old_segment, 10);
|
||||
append_start(&store, session_id, new_segment, 20);
|
||||
append_user(&store, session_id, old_segment, 200, "continued old branch");
|
||||
|
||||
let rows = build_rows(&store, vec![session_id]).unwrap();
|
||||
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert_eq!(rows[0].segment_id, old_segment);
|
||||
assert_eq!(rows[0].updated_at, 200);
|
||||
assert_eq!(rows[0].preview, "user: continued old branch");
|
||||
}
|
||||
|
||||
fn append_start(store: &FsStore, session_id: SessionId, segment_id: SegmentId, ts: u64) {
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
segment_id,
|
||||
&LogEntry::SegmentStart {
|
||||
ts,
|
||||
session_id,
|
||||
system_prompt: None,
|
||||
config: RequestConfig::default(),
|
||||
history: vec![],
|
||||
forked_from: None,
|
||||
compacted_from: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn append_user(
|
||||
store: &FsStore,
|
||||
session_id: SessionId,
|
||||
segment_id: SegmentId,
|
||||
ts: u64,
|
||||
text: &str,
|
||||
) {
|
||||
store
|
||||
.append(
|
||||
session_id,
|
||||
segment_id,
|
||||
&LogEntry::UserInput {
|
||||
ts,
|
||||
segments: vec![protocol::Segment::text(text)],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user