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