1use std::time::Duration;
11
12use qframe::date::DateTime;
13use qframe::prelude::*;
14
15use crate::engine::Engine;
16use crate::profile::HarnessKind;
17use crate::profile::history::{self, Conversation};
18
19use super::plan::LaunchFailure;
20
21pub(super) const SHOW_AFTER: Duration = Duration::from_millis(300);
23
24pub(super) const SHOW_AT_LEAST: Duration = Duration::from_millis(500);
26
27pub(super) const NEWEST: usize = 3;
29
30const REASON_CHARS: usize = 60;
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct HistoryKey {
37 pub workspace: String,
39 pub profile: String,
41}
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub(super) enum Shown<'a> {
46 Nothing,
48 Reading,
50 Failed(&'a str),
52 Conversations(&'a [Conversation]),
54}
55
56#[derive(Debug, Default)]
58pub(super) struct Shelf {
59 answer: Option<Result<Vec<Conversation>, String>>,
61 generation: u64,
63 reading: bool,
65 showing: bool,
67 holding: bool,
69}
70
71impl Shelf {
72 pub(super) fn start(&mut self) -> u64 {
74 self.generation += 1;
75 self.reading = true;
76 self.generation
77 }
78
79 pub(super) fn slow(&mut self, generation: u64) -> bool {
83 if generation != self.generation || !self.reading || self.answer.is_some() || self.showing {
84 return false;
85 }
86 self.showing = true;
87 self.holding = true;
88 true
89 }
90
91 pub(super) fn settled(&mut self) {
93 self.holding = false;
94 self.showing &= self.reading;
95 }
96
97 pub(super) fn answered(&mut self, generation: u64, answer: Result<Vec<Conversation>, String>) {
99 if generation != self.generation {
100 return;
101 }
102 self.answer = Some(answer);
103 self.reading = false;
104 self.showing &= self.holding;
105 }
106
107 pub(super) fn generation(&self) -> u64 {
109 self.generation
110 }
111
112 pub(super) fn title(&self, id: &str) -> Option<&str> {
115 let Some(Ok(found)) = &self.answer else { return None };
116 found.iter().find(|conversation| conversation.id == id)?.title.as_deref()
117 }
118
119 pub(super) fn shown(&self) -> Shown<'_> {
121 if self.showing {
122 return Shown::Reading;
123 }
124 match &self.answer {
125 None => Shown::Nothing,
126 Some(Ok(found)) => Shown::Conversations(found),
127 Some(Err(reason)) => Shown::Failed(reason),
128 }
129 }
130}
131
132pub(super) fn short_title(title: &str) -> String {
135 let line = title.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default();
136 if line.chars().count() <= TITLE_CHARS {
137 return line.to_owned();
138 }
139 let mut short: String = line.chars().take(TITLE_CHARS - 1).collect();
140 short.push('…');
141 short
142}
143
144const TITLE_CHARS: usize = 32;
146
147pub(super) fn read(
152 engine: &Engine,
153 container: &str,
154 harness: HarnessKind,
155 started: &mut dyn FnMut(),
156) -> Result<Vec<Conversation>, String> {
157 history::read_starting(engine, container, harness, started).map_err(|error| reason(&LaunchFailure::from(&error)))
158}
159
160fn reason(failure: &LaunchFailure) -> String {
163 let said = [failure.output.as_str(), failure.command.as_str()]
164 .into_iter()
165 .find_map(|text| text.lines().map(str::trim).find(|line| !line.is_empty()))
166 .unwrap_or_default();
167 let mut cut: String = said.chars().take(REASON_CHARS).collect();
168 if said.chars().count() > REASON_CHARS {
169 cut.push('…');
170 }
171 cut
172}
173
174#[must_use]
180pub(super) fn when(used_ms: i64, now: DateTime) -> String {
181 let used = DateTime::from_unix(used_ms.div_euclid(1000), now.offset_minutes);
182 let time = format!("{:02}:{:02}", used.time.hour, used.time.minute);
183 let days = now.date.to_days() - used.date.to_days();
184 match days {
185 0 => t!("workspace.history.today", time = time),
186 1 => t!("workspace.history.yesterday", time = time),
187 _ => used.date.to_string(),
188 }
189}
190
191#[must_use]
199pub(super) fn claim<'a>(found: &'a [Conversation], opened: u64, taken: &[&str]) -> Option<&'a Conversation> {
200 let since = i64::try_from(opened).unwrap_or(i64::MAX).saturating_mul(1000);
201 found.iter().find(|conversation| conversation.used_ms >= since && !taken.contains(&conversation.id.as_str()))
202}
203
204#[cfg(test)]
205mod tests {
206 use qframe::date::{Date, TimeOfDay};
207
208 use super::*;
209 use crate::ui::settings::testing::translated;
210
211 fn conversation(id: &str, used_ms: i64) -> Conversation {
212 Conversation { id: id.to_owned(), title: None, used_ms }
213 }
214
215 fn now() -> DateTime {
217 DateTime { date: Date::new(2026, 9, 18).expect("a day"), time: TimeOfDay::new(15, 0, 0), offset_minutes: 180 }
218 }
219
220 fn at(date: (i32, u8, u8), time: (u8, u8)) -> i64 {
221 let local = DateTime {
222 date: Date::new(date.0, date.1, date.2).expect("a day"),
223 time: TimeOfDay::new(time.0, time.1, 0),
224 offset_minutes: 180,
225 };
226 local.to_unix() * 1000
227 }
228
229 #[test]
230 fn today_yesterday_and_older_are_written_apart() {
231 translated("en", || {
232 assert_eq!(when(at((2026, 9, 18), (14, 32)), now()), "today 14:32");
233 assert_eq!(when(at((2026, 9, 18), (0, 5)), now()), "today 00:05");
234 assert_eq!(when(at((2026, 9, 17), (9, 10)), now()), "yesterday 09:10");
235 assert_eq!(when(at((2026, 9, 16), (23, 59)), now()), "2026-09-16");
236 assert_eq!(when(at((2025, 12, 31), (8, 0)), now()), "2025-12-31");
237 });
238 }
239
240 #[test]
241 fn the_day_is_the_local_one() {
242 translated("en", || {
243 let late = at((2026, 9, 18), (1, 30));
245 assert_eq!(when(late, now()), "today 01:30");
246 let utc = DateTime { offset_minutes: 0, ..now() };
247 assert_eq!(when(late, utc), "yesterday 22:30", "the same moment, read in UTC");
248 });
249 }
250
251 #[test]
252 fn turkish_writes_bugun_and_dun() {
253 translated("tr", || {
254 assert_eq!(when(at((2026, 9, 18), (14, 32)), now()), "bugün 14:32");
255 assert_eq!(when(at((2026, 9, 17), (9, 10)), now()), "dün 09:10");
256 });
257 }
258
259 #[test]
260 fn a_restored_new_chat_takes_the_newest_conversation_used_after_it_opened_and_not_taken() {
261 let found = [conversation("c", 5_000_000), conversation("b", 4_000_000), conversation("a", 1_000_000)];
262 assert_eq!(claim(&found, 2_000, &[]).map(|c| c.id.as_str()), Some("c"), "the newest after it opened");
263 assert_eq!(claim(&found, 2_000, &["c"]).map(|c| c.id.as_str()), Some("b"), "one another tab has is skipped");
264 assert_eq!(claim(&found, 2_000, &["c", "b"]), None, "one used before it opened is never its own");
265 assert_eq!(claim(&found, 4_000, &["c"]).map(|c| c.id.as_str()), Some("b"), "used the very second it opened");
266 assert_eq!(claim(&[], 0, &[]), None, "nothing read, nothing claimed");
267 }
268
269 #[test]
270 fn a_quick_answer_is_never_shown_as_reading() {
271 let mut shelf = Shelf::default();
272 let first = shelf.start();
273 assert_eq!(shelf.shown(), Shown::Nothing);
274 shelf.answered(first, Ok(vec![conversation("a", 1)]));
275 assert!(!shelf.slow(first), "its timer comes after the answer and finds nothing to show");
276 assert!(matches!(shelf.shown(), Shown::Conversations(found) if found.len() == 1));
277 }
278
279 #[test]
280 fn a_slow_reading_shows_and_stays_its_minimum() {
281 let mut shelf = Shelf::default();
282 let first = shelf.start();
283 assert!(shelf.slow(first));
284 assert_eq!(shelf.shown(), Shown::Reading);
285 shelf.answered(first, Ok(Vec::new()));
286 assert_eq!(shelf.shown(), Shown::Reading, "an answer inside the minimum waits for it");
287 shelf.settled();
288 assert_eq!(shelf.shown(), Shown::Conversations(&[]));
289
290 let mut shelf = Shelf::default();
291 let first = shelf.start();
292 shelf.slow(first);
293 shelf.settled();
294 assert_eq!(shelf.shown(), Shown::Reading, "a reading past its minimum shows until it answers");
295 shelf.answered(first, Err("refused".to_owned()));
296 assert_eq!(shelf.shown(), Shown::Failed("refused"));
297 }
298
299 #[test]
300 fn reading_again_keeps_the_last_answer_on_screen() {
301 let mut shelf = Shelf::default();
302 let first = shelf.start();
303 shelf.answered(first, Ok(vec![conversation("a", 1)]));
304 let second = shelf.start();
305 assert!(!shelf.slow(second), "the rows stay; no indicator takes their place");
306 assert!(matches!(shelf.shown(), Shown::Conversations(found) if found.len() == 1));
307 shelf.answered(first, Ok(Vec::new()));
308 assert!(matches!(shelf.shown(), Shown::Conversations(found) if found.len() == 1), "a stale answer is dropped");
309 shelf.answered(second, Ok(vec![conversation("a", 1), conversation("b", 2)]));
310 assert!(matches!(shelf.shown(), Shown::Conversations(found) if found.len() == 2));
311 }
312
313 #[test]
314 fn a_refusal_is_cut_to_its_first_line() {
315 let failure = LaunchFailure {
316 command: "podman exec x".to_owned(),
317 output: "\nError: no such container\nmore".to_owned(),
318 image_missing: false,
319 };
320 assert_eq!(reason(&failure), "Error: no such container");
321 let long = LaunchFailure { command: String::new(), output: "x".repeat(100), image_missing: false };
322 assert_eq!(reason(&long).chars().count(), REASON_CHARS + 1);
323 let silent = LaunchFailure { command: "podman exec x".to_owned(), output: String::new(), image_missing: false };
324 assert_eq!(reason(&silent), "podman exec x");
325 }
326}