Skip to main content

qcode/ui/workspace/
history.rs

1//! What the page of a blank tab knows about the conversations each profile had in a workspace:
2//! the last answer read out of the profile's container, whether a reading is under way, and
3//! whether that reading has taken long enough to be shown.
4//!
5//! A reading is never drawn during its first [`SHOW_AFTER`], and once drawn it stays at least
6//! [`SHOW_AT_LEAST`], so a quick answer never blinks an indicator and a slow one never flickers
7//! one. A page shown again reads again, but keeps the answer it had on screen until the new one
8//! arrives: the rows stay where they are rather than giving way to an indicator.
9
10use 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
21/// How long a reading runs before it is shown: around here people start to notice waiting.
22pub(super) const SHOW_AFTER: Duration = Duration::from_millis(300);
23
24/// How long a reading, once shown, stays shown even when its answer comes sooner.
25pub(super) const SHOW_AT_LEAST: Duration = Duration::from_millis(500);
26
27/// How many of a profile's newest conversations a section shows before it is asked for all.
28pub(super) const NEWEST: usize = 3;
29
30/// The most characters of an engine's refusal a row carries: the row says why in a few words,
31/// and the engine's whole answer would not fit on it.
32const REASON_CHARS: usize = 60;
33
34/// The conversations of one profile in one workspace, as far as the page knows them.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct HistoryKey {
37    /// The workspace's id.
38    pub workspace: String,
39    /// The profile's name.
40    pub profile: String,
41}
42
43/// What a section shows under its new chat.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub(super) enum Shown<'a> {
46    /// Nothing yet: the first reading has not taken long enough to be noticed.
47    Nothing,
48    /// A reading that has been under way long enough to say so.
49    Reading,
50    /// The reading failed, for this reason.
51    Failed(&'a str),
52    /// The conversations, newest first; empty when the profile has had none in the workspace.
53    Conversations(&'a [Conversation]),
54}
55
56/// One profile's conversations in one workspace, with the reading that keeps them fresh.
57#[derive(Debug, Default)]
58pub(super) struct Shelf {
59    /// The last answer, kept while the next reading runs.
60    answer: Option<Result<Vec<Conversation>, String>>,
61    /// Counts the readings, so the answer of one that was replaced is recognised and dropped.
62    generation: u64,
63    /// Whether a reading is under way.
64    reading: bool,
65    /// Whether the reading is drawn.
66    showing: bool,
67    /// Whether it is drawn because it has not yet been on screen for [`SHOW_AT_LEAST`].
68    holding: bool,
69}
70
71impl Shelf {
72    /// Starts a reading and returns its number.
73    pub(super) fn start(&mut self) -> u64 {
74        self.generation += 1;
75        self.reading = true;
76        self.generation
77    }
78
79    /// The reading `generation` has run for [`SHOW_AFTER`]. It is drawn when it is still the
80    /// current one and there is no earlier answer to keep on screen; returns whether it now is,
81    /// so its minimum time on screen can be counted.
82    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    /// The drawn reading has been on screen for [`SHOW_AT_LEAST`]; it goes once its answer is in.
92    pub(super) fn settled(&mut self) {
93        self.holding = false;
94        self.showing &= self.reading;
95    }
96
97    /// The reading `generation` answered. An answer of a reading that was replaced is dropped.
98    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    /// The number of the last reading started, which is the only one whose answer is taken.
108    pub(super) fn generation(&self) -> u64 {
109        self.generation
110    }
111
112    /// The title the harness gave the conversation `id`, when the last answer holds it and it has
113    /// one.
114    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    /// What the section shows now.
120    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
132/// A conversation's title as a tab's label: its first line, cut to [`TITLE_CHARS`], since a title
133/// that is really the first prompt of the conversation can be a whole paragraph.
134pub(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
144/// The most characters of a conversation's title a tab's label carries.
145const TITLE_CHARS: usize = 32;
146
147/// Reads `harness`'s conversations out of the container `container`, in the words a row can
148/// show when it cannot; `started` is called when the container had to be started for it.
149///
150/// This runs engine commands and waits for them, so it belongs on a background thread.
151pub(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
160/// The first line of what the engine said, short enough for the detail of a row; the command
161/// itself when the engine said nothing.
162fn 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/// When a conversation was last used, as the page's time column writes it: the time of day for
175/// today and yesterday, the date for anything older, all in the offset of `now`.
176///
177/// `now` is a parameter rather than read here, so the page and its tests agree on what "today"
178/// is.
179#[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/// The conversation a new-chat tab brought back from the last session was most likely showing:
192/// the newest one used at or after the tab was opened (`opened`, in seconds) that no other tab
193/// has already taken, given `found` newest first.
194///
195/// A new chat's id is not known when it starts, so this is the one guess the session makes; it
196/// is safe because a conversation used before the tab existed can never be the tab's own, and a
197/// conversation another tab shows is never shown twice.
198#[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    /// 2026-09-18 15:00 in Istanbul (UTC+3).
216    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            // 01:30 on the 18th in Istanbul is still 22:30 on the 17th in UTC.
244            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}