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",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"session-store",
|
"session-store",
|
||||||
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tools",
|
"tools",
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,5 @@ pulldown-cmark = { version = "0.13.3", default-features = false }
|
||||||
llm-worker.workspace = true
|
llm-worker.workspace = true
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tempfile = { workspace = true }
|
||||||
tools = { 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
|
// Phase 1: pick a session in its own inline viewport, dropping the
|
||||||
// viewport before the name dialog opens so each phase gets fresh
|
// viewport before the name dialog opens so each phase gets fresh
|
||||||
// vertical room.
|
// vertical room.
|
||||||
let leaf_segment_id = match picker::run().await? {
|
let segment_id = match picker::run().await? {
|
||||||
PickerOutcome::Picked { segment_id } => segment_id,
|
PickerOutcome::Picked { segment_id } => segment_id,
|
||||||
PickerOutcome::Cancelled => return Ok(()),
|
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>> {
|
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.
|
//! Inline-viewport "pick a session to restore" UX.
|
||||||
//!
|
//!
|
||||||
//! Reads the most recent sessions from the configured store, lets the
|
//! Reads the most recently updated sessions from the configured store,
|
||||||
//! user pick one with the arrow keys, and returns the chosen
|
//! lets the user pick one with the arrow keys, and returns the chosen
|
||||||
//! `SegmentId`. Closes its inline viewport before returning so the
|
//! `SegmentId`. Closes its inline viewport before returning so the
|
||||||
//! caller can open a fresh viewport for the name dialog.
|
//! caller can open a fresh viewport for the name dialog.
|
||||||
//!
|
//!
|
||||||
|
|
@ -62,27 +62,31 @@ impl From<session_store::StoreError> for PickerError {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub enum PickerOutcome {
|
pub enum PickerOutcome {
|
||||||
/// User picked a session; resume at its leaf segment. The pod-cli
|
/// User picked a session; resume at the segment represented by the
|
||||||
/// rehydrates `session_id` via `Store::lookup_session_of` so we only
|
/// selected row. The pod-cli rehydrates `session_id` via
|
||||||
/// need to surface the segment here.
|
/// `Store::lookup_session_of` so we only need to surface the segment
|
||||||
|
/// here.
|
||||||
Picked {
|
Picked {
|
||||||
segment_id: SegmentId,
|
segment_id: SegmentId,
|
||||||
},
|
},
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One row in the picker view. Rendered from the leaf segment of a
|
/// One row in the picker view. Rendered from the most recently updated
|
||||||
/// Session so the user can recognise their conversation at a glance
|
/// segment of a Session so the user can recognise their conversation at a
|
||||||
/// without parsing UUIDs.
|
/// glance without parsing UUIDs.
|
||||||
struct Row {
|
struct Row {
|
||||||
session_id: SessionId,
|
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.
|
/// Last user / assistant snippet, or a `[corrupt]` placeholder.
|
||||||
preview: String,
|
preview: String,
|
||||||
/// `Some(pod_name)` when a live Pod currently holds an allocation
|
/// `Some(pod_name)` when a live Pod currently holds an allocation
|
||||||
/// for this session's leaf segment in `pods.json`. Picking such a
|
/// for this row's segment in `pods.json`. Picking such a row launches
|
||||||
/// row launches `pod --session <UUID>` which will fail with
|
/// `pod --session <UUID>` which will fail with `SegmentConflict` — the
|
||||||
/// `SegmentConflict` — the badge warns the user up-front.
|
/// badge warns the user up-front.
|
||||||
live_pod: Option<String>,
|
live_pod: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -92,25 +96,9 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||||
if sessions.is_empty() {
|
if sessions.is_empty() {
|
||||||
return Err(PickerError::NoSessions);
|
return Err(PickerError::NoSessions);
|
||||||
}
|
}
|
||||||
let mut rows: Vec<Row> = Vec::with_capacity(MAX_ROWS);
|
let rows = build_rows(&store, sessions)?;
|
||||||
for session_id in sessions.into_iter().take(MAX_ROWS) {
|
if rows.is_empty() {
|
||||||
let Some(leaf_segment_id) = store.list_segments(session_id)?.into_iter().next() else {
|
return Err(PickerError::NoSessions);
|
||||||
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 mut selected = 0usize;
|
let mut selected = 0usize;
|
||||||
|
|
@ -132,7 +120,7 @@ pub async fn run() -> Result<PickerOutcome, PickerError> {
|
||||||
Some(Action::Submit) => {
|
Some(Action::Submit) => {
|
||||||
close_viewport(&mut terminal)?;
|
close_viewport(&mut terminal)?;
|
||||||
return Ok(PickerOutcome::Picked {
|
return Ok(PickerOutcome::Picked {
|
||||||
segment_id: rows[selected].leaf_segment_id,
|
segment_id: rows[selected].segment_id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Some(Action::Cancel) => {
|
Some(Action::Cancel) => {
|
||||||
|
|
@ -176,10 +164,86 @@ fn open_default_store() -> Result<FsStore, PickerError> {
|
||||||
Ok(FsStore::new(&dir)?)
|
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) {
|
match store.read_all(session_id, segment_id) {
|
||||||
Ok(entries) => last_message_preview(&entries).unwrap_or_else(|| "[empty]".to_string()),
|
Ok(entries) => (
|
||||||
Err(_) => "[corrupt]".to_string(),
|
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();
|
let s = id.to_string();
|
||||||
s.chars().take(8).collect()
|
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