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