Vectrum
Sign In

New chat turn navigator

JjohannSocommitted 1 parent 6676930commit78a6a73

Showing 2 changed files with 133 additions and 0 deletions.

+5−0src/ui/chat.rsView File
@@ -6,6 +6,7 @@
66 mod dock;
77 mod find;
88 mod footer;
9+ mod rail;
910 mod strip;
1011 mod timeline;
1112 mod usage_bar;
@@ -190,6 +191,8 @@ pub struct ChatView {
190191 history: Option<(usize, String)>,
191192 /// The find bar, while open (Ctrl+F).
192193 find: Option<find::Find>,
194+ /// The prompt whose dash on the turn rail the pointer is on.
195+ rail_hover: Option<usize>,
193196 /// Runs of tool calls unfolded in the timeline, by their first call.
194197 expanded_groups: std::collections::HashSet<usize>,
195198 /// Long user messages unfolded in the timeline.
@@ -323,6 +326,7 @@ impl ChatView {
323326 files_task: Task::ready(()),
324327 history: None,
325328 find: None,
329+ rail_hover: None,
326330 expanded_groups: Default::default(),
327331 expanded_messages: Default::default(),
328332 stash: Vec::new(),
@@ -1569,6 +1573,7 @@ impl ChatView {
15691573 .with_row_style(StyleRefinement::default().pb_0())
15701574 .with_bottom_fade(metal::palette(cx).s),
15711575 )
1576+ .children(self.render_turn_rail(cx))
15721577 .children(self.render_find_bar(cx))
15731578 }
15741579
+128−0src/ui/chat/rail.rsaddedView File
@@ -0,0 +1,128 @@
1+ //! The turn rail, like T3 Code's: a short dash per prompt down the
2+ //! conversation's left edge. Hovering one shows the prompt and how its
3+ //! answer begins; a click scrolls to it.
4+
5+ use gpui_kit::component::v_flex;
6+ use gpui_kit::prelude::FluentBuilder as _;
7+ use gpui_kit::*;
8+
9+ use super::ChatView;
10+ use crate::session::ChatItem;
11+ use crate::ui::metal;
12+
13+ /// Each dash's share of the rail; many prompts squeeze them closer.
14+ const SLOT: f32 = 8.;
15+
16+ impl ChatView {
17+ /// The rail, floating over the conversation's left edge, once the
18+ /// thread has more than one prompt.
19+ pub(super) fn render_turn_rail(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
20+ let p = metal::palette(cx);
21+ let items = &self.session.read(cx).items;
22+ let prompts: Vec<(usize, usize)> = items
23+ .iter()
24+ .enumerate()
25+ .filter_map(|(ix, item)| match item {
26+ ChatItem::User { text, .. } if !text.starts_with(crate::orchestrator::RESULT_PREFIX) => Some((ix, text.chars().count())),
27+ _ => None,
28+ })
29+ .collect();
30+ if prompts.len() < 2 {
31+ return None;
32+ }
33+ let hovered = self.rail_hover.filter(|ix| prompts.iter().any(|(prompt, _)| prompt == ix));
34+ let mut card = hovered.map(|ix| self.render_turn_card(ix, cx));
35+ let dashes = prompts.into_iter().map(|(ix, chars)| {
36+ let on = hovered == Some(ix);
37+ // Longer prompts draw longer dashes, within the rows' margin.
38+ let width = 8. + (chars as f32 / 40.).min(10.);
39+ div()
40+ .id(("turn-dash", ix))
41+ .relative()
42+ .flex_basis(px(SLOT))
43+ .min_h(px(2.))
44+ .w_full()
45+ .pl(px(4.))
46+ .flex()
47+ .items_center()
48+ .cursor_pointer()
49+ .on_hover(cx.listener(move |this, hovered: &bool, _, cx| {
50+ if *hovered {
51+ this.rail_hover = Some(ix);
52+ } else if this.rail_hover == Some(ix) {
53+ this.rail_hover = None;
54+ }
55+ cx.notify();
56+ }))
57+ .on_click(cx.listener(move |this, _, _, cx| {
58+ this.scroller.update(cx, |scroller, cx| {
59+ scroller.scroll_to_item(ix, cx);
60+ });
61+ }))
62+ .child(
63+ div()
64+ .h(px(2.))
65+ .w(px(if on { width + 4. } else { width }))
66+ .rounded_full()
67+ .bg(if on { p.ink2 } else { p.ink3.opacity(0.45) }),
68+ )
69+ .when_some(on.then(|| card.take()).flatten(), |this, card| {
70+ // Centered on its dash, whatever the card's height.
71+ this.child(
72+ deferred(div().absolute().left(px(36.)).top_0().bottom_0().flex().items_center().child(card)).with_priority(1),
73+ )
74+ })
75+ });
76+ Some(
77+ v_flex()
78+ .absolute()
79+ .left(px(4.))
80+ .top(px(48.))
81+ .bottom(px(48.))
82+ .w(px(28.))
83+ .justify_center()
84+ .children(dashes)
85+ .into_any_element(),
86+ )
87+ }
88+
89+ /// A prompt's first line over the start of its answer.
90+ fn render_turn_card(&self, ix: usize, cx: &mut Context<Self>) -> AnyElement {
91+ let p = metal::palette(cx);
92+ let items = &self.session.read(cx).items;
93+ let prompt = match items.get(ix) {
94+ Some(ChatItem::User { text, .. }) => text.lines().map(str::trim).find(|line| !line.is_empty()).unwrap_or_default().to_string(),
95+ _ => String::new(),
96+ };
97+ let next = items[ix + 1..].iter().position(|item| matches!(item, ChatItem::User { .. })).map_or(items.len(), |n| ix + 1 + n);
98+ let answer = items[ix + 1..next].iter().rev().find_map(|item| match item {
99+ ChatItem::Assistant(answer) if !answer.text.trim().is_empty() => Some(plain(&answer.text)),
100+ _ => None,
101+ });
102+ v_flex()
103+ .w(px(420.))
104+ .px(px(16.))
105+ .py(px(12.))
106+ .gap(px(4.))
107+ .rounded(px(14.))
108+ .bg(p.menu_bg)
109+ .shadow(p.raise.clone())
110+ .child(div().truncate().text_size(px(14.)).font_weight(FontWeight::MEDIUM).text_color(p.ink).child(prompt))
111+ .when_some(answer, |this, answer| {
112+ this.child(div().text_size(px(13.)).line_height(px(20.)).text_color(p.ink3).line_clamp(3).text_ellipsis().child(answer))
113+ })
114+ .into_any_element()
115+ }
116+ }
117+
118+ /// An answer's opening as plain running text: no Markdown markers, one
119+ /// line, cut short.
120+ fn plain(text: &str) -> String {
121+ let words: Vec<&str> = text
122+ .split_whitespace()
123+ .map(|word| word.trim_start_matches(['#', '>']).trim_matches(['*', '`', '_']))
124+ .filter(|word| !word.is_empty() && *word != "-")
125+ .take(80)
126+ .collect();
127+ words.join(" ")
128+ }