Skip to main content

qcode/ui/workspace/
blank.rs

1//! The page of a blank tab: what the tab should open, as one list sectioned by profile.
2//!
3//! The list is drawn from a small model, [`Row`], and a row the list reports by its position is
4//! found again in the same model, so a profile's section can grow rows of its own without the
5//! list and what its rows do drifting apart.
6//!
7//! Under each profile's new chat come its newest conversations in the workspace, read out of the
8//! profile's container while the page is shown (see [`super::history`]), and a row that shows
9//! the rest of them.
10
11use qframe::date::DateTime;
12use qframe::prelude::*;
13
14use crate::profile::history::Conversation;
15use crate::ui::page;
16
17use super::history::{self, HistoryKey, NEWEST, Shown};
18use super::{Msg, OpenWorkspace, TabKey, TabKind, WorkspaceScreen};
19
20/// The name the list of the page is focused by.
21pub(super) const CHOICES_ID: &str = "workspace-choices";
22
23/// What a blank tab can turn into.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum Choice {
26    /// A shell in the workspace's own container.
27    Shell,
28    /// A new conversation with the harness of the profile of this name.
29    NewChat(String),
30    /// A conversation the harness of the profile of this name had before, by the harness's id.
31    Resume(String, String),
32    /// The window of the desktop harness of the profile of this name, on the person's own screen.
33    Window(String),
34}
35
36/// One row of the page.
37#[derive(Debug, Clone, PartialEq, Eq)]
38enum Row {
39    /// Opens a shell.
40    Shell,
41    /// The empty row before a section.
42    Gap,
43    /// The heading of the section of the profile at this position of the workspace's profiles.
44    Profile(usize),
45    /// Starts a new conversation with the profile of this name.
46    NewChat(String),
47    /// Opens the window of the desktop profile of this name.
48    Window(String),
49    /// Opens again a conversation the profile of this name had.
50    Chat(String, Conversation),
51    /// Shows every conversation of a profile in place, when this many more are hidden.
52    ShowAll(HistoryKey, usize),
53    /// Says the profile has had no conversation in the workspace yet.
54    NoChats,
55    /// Says the profile's conversations are being read.
56    Reading,
57    /// Says the profile's conversations could not be read, and why.
58    Unread(String),
59    /// Leads to the profiles screen, when there is no profile to choose from at all.
60    MakeProfile,
61}
62
63impl Row {
64    /// Whether the list lets the keyboard and the pointer rest on the row.
65    fn selectable(&self) -> bool {
66        !matches!(self, Self::Gap | Self::Profile(_))
67    }
68
69    /// What choosing the row in the blank tab `tab` asks for. A heading and a gap ask nothing,
70    /// and neither does a row that only says how a section stands.
71    fn message(&self, tab: TabKey) -> Option<Msg> {
72        match self {
73            Self::Shell => Some(Msg::Choose(tab, Choice::Shell)),
74            Self::NewChat(name) => Some(Msg::Choose(tab, Choice::NewChat(name.clone()))),
75            Self::Window(name) => Some(Msg::Choose(tab, Choice::Window(name.clone()))),
76            Self::Chat(name, conversation) => {
77                Some(Msg::Choose(tab, Choice::Resume(name.clone(), conversation.id.clone())))
78            }
79            Self::ShowAll(key, _) => Some(Msg::ShowAll(key.clone())),
80            Self::MakeProfile => Some(Msg::ManageProfiles),
81            Self::Gap | Self::Profile(_) | Self::NoChats | Self::Reading | Self::Unread(_) => None,
82        }
83    }
84
85    /// Whether `other` is this row, even when what it shows was read again since: a
86    /// conversation is the same one while its id is, whatever its title and time say now.
87    fn same(&self, other: &Self) -> bool {
88        match (self, other) {
89            (Self::Chat(profile, conversation), Self::Chat(other_profile, other_conversation)) => {
90                profile == other_profile && conversation.id == other_conversation.id
91            }
92            (Self::ShowAll(key, _), Self::ShowAll(other_key, _)) => key == other_key,
93            _ => self == other,
94        }
95    }
96}
97
98/// The rows of the page for `workspace`: the shell first, then a section for every profile in the
99/// order the workspace offers them, or the way to a first profile when there is none.
100///
101/// Without an engine nothing was read, so a section is only its new chat.
102fn rows(screen: &WorkspaceScreen, workspace: &OpenWorkspace) -> Vec<Row> {
103    let mut rows = vec![Row::Shell];
104    if workspace.profiles().is_empty() {
105        rows.extend([Row::Gap, Row::MakeProfile]);
106    }
107    for (index, profile) in workspace.profiles().iter().enumerate() {
108        let name = profile.name.as_str();
109        // A desktop profile's section is one row: its window. It has no conversations QCode can
110        // list — the shape of what the agent inside writes was never read — so none are offered.
111        if profile.harness.desktop().is_some() {
112            rows.extend([Row::Gap, Row::Profile(index), Row::Window(name.to_owned())]);
113            continue;
114        }
115        rows.extend([Row::Gap, Row::Profile(index), Row::NewChat(name.to_owned())]);
116        if screen.engine().is_none() {
117            continue;
118        }
119        let Some(shelf) = workspace.history.get(name) else { continue };
120        match shelf.shown() {
121            Shown::Nothing => {}
122            Shown::Reading => rows.push(Row::Reading),
123            Shown::Failed(reason) => rows.push(Row::Unread(reason.to_owned())),
124            Shown::Conversations([]) => rows.push(Row::NoChats),
125            Shown::Conversations(found) => {
126                // A conversation a tab of the workspace shows is left out: opening it again would
127                // give it a second tab typing into it, and the tab it is in is on the strip. That
128                // covers the one a shared server made for a tab that has not been written in yet,
129                // which goes away with its tab rather than being worth offering.
130                let found: Vec<&Conversation> =
131                    found.iter().filter(|conversation| !shown_in_a_tab(workspace, name, &conversation.id)).collect();
132                // Every one of them open is not "no chats yet": the section is its new chat alone.
133                if found.is_empty() {
134                    continue;
135                }
136                let key = HistoryKey { workspace: workspace.id().to_owned(), profile: name.to_owned() };
137                let count = if screen.expanded.contains(&key) { found.len() } else { found.len().min(NEWEST) };
138                rows.extend(
139                    found[..count].iter().map(|conversation| Row::Chat(name.to_owned(), (*conversation).clone())),
140                );
141                if count < found.len() {
142                    rows.push(Row::ShowAll(key, found.len() - count));
143                }
144            }
145        }
146    }
147    rows
148}
149
150/// Whether a tab of `workspace` shows the conversation `id` of the profile `profile`.
151fn shown_in_a_tab(workspace: &OpenWorkspace, profile: &str, id: &str) -> bool {
152    workspace
153        .tabs()
154        .iter()
155        .any(|tab| matches!(tab.kind(), TabKind::Profile(name) if name == profile) && tab.conversation() == Some(id))
156}
157
158/// The row the keyboard rests on: `wanted` when a row can rest there, otherwise the shell, which
159/// is always the first row.
160fn resting_row(rows: &[Row], wanted: usize) -> usize {
161    if rows.get(wanted).is_some_and(Row::selectable) { wanted } else { 0 }
162}
163
164/// Applies `change` to the screen, keeping the keyboard on the row it rested on when the change
165/// adds or takes away rows above it: a section whose conversations arrive while the person is
166/// further down the page must not move the selection onto another row under their hands.
167pub(super) fn keep_row(screen: &mut WorkspaceScreen, change: impl FnOnce(&mut WorkspaceScreen)) {
168    let page = |screen: &WorkspaceScreen| {
169        screen
170            .workspace()
171            .filter(|workspace| workspace.active_tab().is_some_and(|tab| tab.kind() == &TabKind::New))
172            .map(|workspace| rows(screen, workspace))
173    };
174    let before = page(screen).and_then(|rows| rows.get(screen.blank_row).cloned());
175    change(screen);
176    let (Some(before), Some(after)) = (before, page(screen)) else { return };
177    if let Some(index) = after.iter().position(|row| row.same(&before)) {
178        screen.blank_row = index;
179    }
180}
181
182/// The cells a row without an icon is moved in by, so its label starts under the label of the
183/// new chat above it: the width of the new chat's glyph and the gap after it.
184fn indent(ui: &View<'_, Msg>) -> String {
185    let glyph = ui.env().icons().glyph("add");
186    " ".repeat(usize::from(qframe::text::width(&glyph)) + 1)
187}
188
189/// How `row` is drawn. Without an engine every row is faint: it can still be looked at and
190/// moved through, and the line above the list says why choosing it does nothing. A row that
191/// only says how a section stands is always faint, since there is nothing to choose in it.
192fn item(workspace: &OpenWorkspace, row: &Row, look: &Look, ready: bool) -> ListItem {
193    let dot = look.dot.as_str();
194    match row {
195        Row::Shell => ListItem::new(t!("workspace.tab.shell"))
196            .icon("prompt", None)
197            .detail(t!("workspace.choose.shell-detail"))
198            .faint(!ready),
199        Row::Gap => ListItem::gap(),
200        Row::Profile(index) => {
201            let Some(profile) = workspace.profiles().get(*index) else { return ListItem::gap() };
202            let name = profile.name.as_str();
203            let harness = profile.harness.record().display_name;
204            // A profile the workspace does not carry yet is offered all the same; its heading says
205            // that choosing from it adds it, so the file changing is no surprise.
206            ListItem::header(if workspace.carries(name) {
207                t!("workspace.choose.section", profile = name, harness = harness, dot = dot)
208            } else {
209                t!("workspace.choose.section-adds", profile = name, harness = harness, dot = dot)
210            })
211        }
212        Row::NewChat(_) => ListItem::new(t!("workspace.choose.new-chat")).icon("add", None).faint(!ready),
213        Row::Window(_) => ListItem::new(t!("workspace.choose.window"))
214            .icon("window-maximize", None)
215            .detail(t!("workspace.choose.window-detail"))
216            .faint(!ready),
217        Row::Chat(_, conversation) => {
218            let title = conversation.title.clone().unwrap_or_else(|| t!("workspace.history.untitled"));
219            ListItem::new(format!("{}{title}", look.indent))
220                .detail(history::when(conversation.used_ms, look.now))
221                .faint(!ready)
222        }
223        Row::ShowAll(_, more) => {
224            ListItem::new(format!("{}{}", look.indent, t!("workspace.history.show-all", more = *more, dot = dot)))
225                .faint(!ready)
226        }
227        Row::NoChats => ListItem::new(format!("{}{}", look.indent, t!("workspace.history.none"))).faint(true),
228        Row::Reading => ListItem::new(format!("{}{}", look.indent, t!("workspace.history.reading"))).faint(true),
229        Row::Unread(reason) => ListItem::new(format!("{}{}", look.indent, t!("workspace.history.unread")))
230            .detail(reason.clone())
231            .faint(true),
232        Row::MakeProfile => ListItem::new(t!("workspace.make-profile"))
233            .icon("add", None)
234            .detail(t!("workspace.no-profiles"))
235            .faint(!ready),
236    }
237}
238
239/// What every row of one frame of the page is drawn with.
240struct Look {
241    /// The separator of a heading, in the glyph mode of the frame.
242    dot: String,
243    /// What moves a row in under the label of the new chat.
244    indent: String,
245    /// Now, so "today" and "yesterday" mean the same for every row.
246    now: DateTime,
247}
248
249/// Draws the page of the blank tab `tab` of `workspace`: a question, and the list that answers it,
250/// on a page of the width Settings keeps, so a row's detail stays one glance from its label on a
251/// wide terminal.
252pub(super) fn view(screen: &WorkspaceScreen, workspace: &OpenWorkspace, tab: TabKey, ui: &mut View<'_, Msg>) {
253    let ready = screen.engine().is_some();
254    let rows = rows(screen, workspace);
255    let look =
256        Look { dot: ui.env().icons().glyph("bullet").into_owned(), indent: indent(ui), now: DateTime::now_local() };
257    let items: Vec<ListItem> = rows.iter().map(|row| item(workspace, row, &look, ready)).collect();
258    let selected = resting_row(&rows, screen.blank_row);
259    page::column(ui, page::WIDTH, |ui| {
260        ui.column(|ui| {
261            ui.add(Text::new(t!("workspace.choose.title")).role("title"));
262            if !ready {
263                ui.add(Text::new(t!("workspace.no-engine")).role("secondary"));
264            }
265            // A row is chosen by its name; the note beside it gives way first on a narrow middle.
266            let list = List::new(items)
267                .label_first(true)
268                .selected(Some(selected))
269                .on_select(Msg::HighlightChoice)
270                // A row that only says how a section stands has nothing to choose; activating it
271                // leaves the keyboard on it, where the pointer or the keys put it. The list never
272                // activates a heading or a gap, since it does not let the keyboard rest there.
273                .on_activate(move |index| {
274                    rows.get(index).and_then(|row| row.message(tab)).unwrap_or(Msg::HighlightChoice(index))
275                });
276            ui.add(list.wrap(true)).id(CHOICES_ID).fill();
277        })
278        .gap(1)
279        .padding(Padding::symmetric(1, 2))
280        .fill()
281        .id("workspace-blank");
282    });
283}