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