1use crate::{
2 DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
3 prelude::*,
4};
5
6use gpui::{AnyView, ClickEvent, SharedString};
7
8#[derive(IntoElement, RegisterComponent)]
9pub struct ThreadItem {
10 id: ElementId,
11 icon: IconName,
12 title: SharedString,
13 timestamp: SharedString,
14 running: bool,
15 generation_done: bool,
16 selected: bool,
17 hovered: bool,
18 added: Option<usize>,
19 removed: Option<usize>,
20 worktree: Option<SharedString>,
21 highlight_positions: Vec<usize>,
22 worktree_highlight_positions: Vec<usize>,
23 on_click: Option<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
24 on_hover: Box<dyn Fn(&bool, &mut Window, &mut App) + 'static>,
25 action_slot: Option<AnyElement>,
26 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView + 'static>>,
27}
28
29impl ThreadItem {
30 pub fn new(id: impl Into<ElementId>, title: impl Into<SharedString>) -> Self {
31 Self {
32 id: id.into(),
33 icon: IconName::ZedAgent,
34 title: title.into(),
35 timestamp: "".into(),
36 running: false,
37 generation_done: false,
38 selected: false,
39 hovered: false,
40 added: None,
41 removed: None,
42 worktree: None,
43 highlight_positions: Vec::new(),
44 worktree_highlight_positions: Vec::new(),
45 on_click: None,
46 on_hover: Box::new(|_, _, _| {}),
47 action_slot: None,
48 tooltip: None,
49 }
50 }
51
52 pub fn timestamp(mut self, timestamp: impl Into<SharedString>) -> Self {
53 self.timestamp = timestamp.into();
54 self
55 }
56
57 pub fn icon(mut self, icon: IconName) -> Self {
58 self.icon = icon;
59 self
60 }
61
62 pub fn running(mut self, running: bool) -> Self {
63 self.running = running;
64 self
65 }
66
67 pub fn generation_done(mut self, generation_done: bool) -> Self {
68 self.generation_done = generation_done;
69 self
70 }
71
72 pub fn selected(mut self, selected: bool) -> Self {
73 self.selected = selected;
74 self
75 }
76
77 pub fn added(mut self, added: usize) -> Self {
78 self.added = Some(added);
79 self
80 }
81
82 pub fn removed(mut self, removed: usize) -> Self {
83 self.removed = Some(removed);
84 self
85 }
86
87 pub fn worktree(mut self, worktree: impl Into<SharedString>) -> Self {
88 self.worktree = Some(worktree.into());
89 self
90 }
91
92 pub fn highlight_positions(mut self, positions: Vec<usize>) -> Self {
93 self.highlight_positions = positions;
94 self
95 }
96
97 pub fn worktree_highlight_positions(mut self, positions: Vec<usize>) -> Self {
98 self.worktree_highlight_positions = positions;
99 self
100 }
101
102 pub fn hovered(mut self, hovered: bool) -> Self {
103 self.hovered = hovered;
104 self
105 }
106
107 pub fn on_click(
108 mut self,
109 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
110 ) -> Self {
111 self.on_click = Some(Box::new(handler));
112 self
113 }
114
115 pub fn on_hover(mut self, on_hover: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
116 self.on_hover = Box::new(on_hover);
117 self
118 }
119
120 pub fn action_slot(mut self, element: impl IntoElement) -> Self {
121 self.action_slot = Some(element.into_any_element());
122 self
123 }
124
125 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
126 self.tooltip = Some(Box::new(tooltip));
127 self
128 }
129}
130
131impl RenderOnce for ThreadItem {
132 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
133 let clr = cx.theme().colors();
134 // let dot_separator = || {
135 // Label::new("•")
136 // .size(LabelSize::Small)
137 // .color(Color::Muted)
138 // .alpha(0.5)
139 // };
140
141 let icon_container = || h_flex().size_4().justify_center();
142 let agent_icon = Icon::new(self.icon)
143 .color(Color::Muted)
144 .size(IconSize::Small);
145
146 let icon = if self.generation_done {
147 icon_container().child(DecoratedIcon::new(
148 agent_icon,
149 Some(
150 IconDecoration::new(
151 IconDecorationKind::Dot,
152 cx.theme().colors().surface_background,
153 cx,
154 )
155 .color(cx.theme().colors().text_accent)
156 .position(gpui::Point {
157 x: px(-2.),
158 y: px(-2.),
159 }),
160 ),
161 ))
162 } else {
163 icon_container().child(agent_icon)
164 };
165
166 let running_or_action = self.running || (self.hovered && self.action_slot.is_some());
167
168 // let has_no_changes = self.added.is_none() && self.removed.is_none();
169
170 let title = self.title;
171 let highlight_positions = self.highlight_positions;
172 let title_label = if highlight_positions.is_empty() {
173 Label::new(title).truncate().into_any_element()
174 } else {
175 HighlightedLabel::new(title, highlight_positions)
176 .truncate()
177 .into_any_element()
178 };
179
180 v_flex()
181 .id(self.id.clone())
182 .cursor_pointer()
183 .map(|this| {
184 if self.worktree.is_some() {
185 this.p_2()
186 } else {
187 this.px_2().py_1()
188 }
189 })
190 .when(self.selected, |s| s.bg(clr.element_active))
191 .hover(|s| s.bg(clr.element_hover))
192 .on_hover(self.on_hover)
193 .child(
194 h_flex()
195 .min_w_0()
196 .w_full()
197 .gap_2()
198 .justify_between()
199 .child(
200 h_flex()
201 .id("content")
202 .min_w_0()
203 .flex_1()
204 .gap_1p5()
205 .child(icon)
206 .child(title_label)
207 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
208 )
209 .when(running_or_action, |this| {
210 this.child(
211 h_flex()
212 .gap_1()
213 .when(self.running, |this| {
214 this.child(
215 icon_container()
216 .child(SpinnerLabel::new().color(Color::Accent)),
217 )
218 })
219 .when(self.hovered, |this| {
220 this.when_some(self.action_slot, |this, slot| this.child(slot))
221 }),
222 )
223 }),
224 )
225 .when_some(self.worktree, |this, worktree| {
226 let worktree_highlight_positions = self.worktree_highlight_positions;
227 let worktree_label = if worktree_highlight_positions.is_empty() {
228 Label::new(worktree)
229 .size(LabelSize::Small)
230 .color(Color::Muted)
231 .truncate_start()
232 .into_any_element()
233 } else {
234 HighlightedLabel::new(worktree, worktree_highlight_positions)
235 .size(LabelSize::Small)
236 .color(Color::Muted)
237 .into_any_element()
238 };
239
240 this.child(
241 h_flex()
242 .min_w_0()
243 .gap_1p5()
244 .child(icon_container()) // Icon Spacing
245 .child(worktree_label)
246 // TODO: Uncomment the elements below when we're ready to expose this data
247 // .child(dot_separator())
248 // .child(
249 // Label::new(self.timestamp)
250 // .size(LabelSize::Small)
251 // .color(Color::Muted),
252 // )
253 // .child(
254 // Label::new("•")
255 // .size(LabelSize::Small)
256 // .color(Color::Muted)
257 // .alpha(0.5),
258 // )
259 // .when(has_no_changes, |this| {
260 // this.child(
261 // Label::new("No Changes")
262 // .size(LabelSize::Small)
263 // .color(Color::Muted),
264 // )
265 // })
266 .when(self.added.is_some() || self.removed.is_some(), |this| {
267 this.child(DiffStat::new(
268 self.id,
269 self.added.unwrap_or(0),
270 self.removed.unwrap_or(0),
271 ))
272 }),
273 )
274 })
275 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
276 }
277}
278
279impl Component for ThreadItem {
280 fn scope() -> ComponentScope {
281 ComponentScope::Agent
282 }
283
284 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
285 let container = || {
286 v_flex()
287 .w_72()
288 .border_1()
289 .border_color(cx.theme().colors().border_variant)
290 .bg(cx.theme().colors().panel_background)
291 };
292
293 let thread_item_examples = vec![
294 single_example(
295 "Default",
296 container()
297 .child(
298 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
299 .icon(IconName::AiOpenAi)
300 .timestamp("1:33 AM"),
301 )
302 .into_any_element(),
303 ),
304 single_example(
305 "Generation Done",
306 container()
307 .child(
308 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
309 .timestamp("12:12 AM")
310 .generation_done(true),
311 )
312 .into_any_element(),
313 ),
314 single_example(
315 "Running Agent",
316 container()
317 .child(
318 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
319 .icon(IconName::AiClaude)
320 .timestamp("7:30 PM")
321 .running(true),
322 )
323 .into_any_element(),
324 ),
325 single_example(
326 "In Worktree",
327 container()
328 .child(
329 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
330 .icon(IconName::AiClaude)
331 .timestamp("7:37 PM")
332 .worktree("link-agent-panel"),
333 )
334 .into_any_element(),
335 ),
336 single_example(
337 "With Changes",
338 container()
339 .child(
340 ThreadItem::new("ti-5", "Managing user and project settings interactions")
341 .icon(IconName::AiClaude)
342 .timestamp("7:37 PM")
343 .added(10)
344 .removed(3),
345 )
346 .into_any_element(),
347 ),
348 single_example(
349 "Selected Item",
350 container()
351 .child(
352 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
353 .icon(IconName::AiGemini)
354 .timestamp("3:00 PM")
355 .selected(true),
356 )
357 .into_any_element(),
358 ),
359 ];
360
361 Some(
362 example_group(thread_item_examples)
363 .vertical()
364 .into_any_element(),
365 )
366 }
367}