1use crate::{
2 DecoratedIcon, DiffStat, HighlightedLabel, IconDecoration, IconDecorationKind, SpinnerLabel,
3 prelude::*,
4};
5
6use gpui::{AnyView, ClickEvent, 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 title: SharedString,
22 timestamp: SharedString,
23 running: bool,
24 generation_done: 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 title: title.into(),
45 timestamp: "".into(),
46 running: false,
47 generation_done: 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 running(mut self, running: bool) -> Self {
74 self.running = running;
75 self
76 }
77
78 pub fn generation_done(mut self, generation_done: bool) -> Self {
79 self.generation_done = generation_done;
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 = Icon::new(self.icon)
159 .color(Color::Muted)
160 .size(IconSize::Small);
161
162 let decoration = if self.status == AgentThreadStatus::WaitingForConfirmation {
163 Some(
164 IconDecoration::new(
165 IconDecorationKind::Triangle,
166 cx.theme().colors().surface_background,
167 cx,
168 )
169 .color(cx.theme().status().warning)
170 .position(gpui::Point {
171 x: px(-2.),
172 y: px(-2.),
173 }),
174 )
175 } else if self.status == AgentThreadStatus::Error {
176 Some(
177 IconDecoration::new(
178 IconDecorationKind::X,
179 cx.theme().colors().surface_background,
180 cx,
181 )
182 .color(cx.theme().status().error)
183 .position(gpui::Point {
184 x: px(-2.),
185 y: px(-2.),
186 }),
187 )
188 } else if self.generation_done {
189 Some(
190 IconDecoration::new(
191 IconDecorationKind::Dot,
192 cx.theme().colors().surface_background,
193 cx,
194 )
195 .color(cx.theme().colors().text_accent)
196 .position(gpui::Point {
197 x: px(-2.),
198 y: px(-2.),
199 }),
200 )
201 } else {
202 None
203 };
204
205 let icon = if let Some(decoration) = decoration {
206 icon_container().child(DecoratedIcon::new(agent_icon, Some(decoration)))
207 } else {
208 icon_container().child(agent_icon)
209 };
210
211 let running_or_action = self.running || (self.hovered && self.action_slot.is_some());
212
213 // let has_no_changes = self.added.is_none() && self.removed.is_none();
214
215 let title = self.title;
216 let highlight_positions = self.highlight_positions;
217 let title_label = if highlight_positions.is_empty() {
218 Label::new(title).truncate().into_any_element()
219 } else {
220 HighlightedLabel::new(title, highlight_positions)
221 .truncate()
222 .into_any_element()
223 };
224
225 v_flex()
226 .id(self.id.clone())
227 .cursor_pointer()
228 .map(|this| {
229 if self.worktree.is_some() {
230 this.p_2()
231 } else {
232 this.px_2().py_1()
233 }
234 })
235 .when(self.selected, |s| s.bg(clr.element_active))
236 .hover(|s| s.bg(clr.element_hover))
237 .on_hover(self.on_hover)
238 .child(
239 h_flex()
240 .min_w_0()
241 .w_full()
242 .gap_2()
243 .justify_between()
244 .child(
245 h_flex()
246 .id("content")
247 .min_w_0()
248 .flex_1()
249 .gap_1p5()
250 .child(icon)
251 .child(title_label)
252 .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)),
253 )
254 .when(running_or_action, |this| {
255 this.child(
256 h_flex()
257 .gap_1()
258 .when(self.running, |this| {
259 this.child(
260 icon_container()
261 .child(SpinnerLabel::new().color(Color::Accent)),
262 )
263 })
264 .when(self.hovered, |this| {
265 this.when_some(self.action_slot, |this, slot| this.child(slot))
266 }),
267 )
268 }),
269 )
270 .when_some(self.worktree, |this, worktree| {
271 let worktree_highlight_positions = self.worktree_highlight_positions;
272 let worktree_label = if worktree_highlight_positions.is_empty() {
273 Label::new(worktree)
274 .size(LabelSize::Small)
275 .color(Color::Muted)
276 .truncate_start()
277 .into_any_element()
278 } else {
279 HighlightedLabel::new(worktree, worktree_highlight_positions)
280 .size(LabelSize::Small)
281 .color(Color::Muted)
282 .into_any_element()
283 };
284
285 this.child(
286 h_flex()
287 .min_w_0()
288 .gap_1p5()
289 .child(icon_container()) // Icon Spacing
290 .child(worktree_label)
291 // TODO: Uncomment the elements below when we're ready to expose this data
292 // .child(dot_separator())
293 // .child(
294 // Label::new(self.timestamp)
295 // .size(LabelSize::Small)
296 // .color(Color::Muted),
297 // )
298 // .child(
299 // Label::new("•")
300 // .size(LabelSize::Small)
301 // .color(Color::Muted)
302 // .alpha(0.5),
303 // )
304 // .when(has_no_changes, |this| {
305 // this.child(
306 // Label::new("No Changes")
307 // .size(LabelSize::Small)
308 // .color(Color::Muted),
309 // )
310 // })
311 .when(self.added.is_some() || self.removed.is_some(), |this| {
312 this.child(DiffStat::new(
313 self.id,
314 self.added.unwrap_or(0),
315 self.removed.unwrap_or(0),
316 ))
317 }),
318 )
319 })
320 .when_some(self.on_click, |this, on_click| this.on_click(on_click))
321 }
322}
323
324impl Component for ThreadItem {
325 fn scope() -> ComponentScope {
326 ComponentScope::Agent
327 }
328
329 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
330 let container = || {
331 v_flex()
332 .w_72()
333 .border_1()
334 .border_color(cx.theme().colors().border_variant)
335 .bg(cx.theme().colors().panel_background)
336 };
337
338 let thread_item_examples = vec![
339 single_example(
340 "Default",
341 container()
342 .child(
343 ThreadItem::new("ti-1", "Linking to the Agent Panel Depending on Settings")
344 .icon(IconName::AiOpenAi)
345 .timestamp("1:33 AM"),
346 )
347 .into_any_element(),
348 ),
349 single_example(
350 "Generation Done",
351 container()
352 .child(
353 ThreadItem::new("ti-2", "Refine thread view scrolling behavior")
354 .timestamp("12:12 AM")
355 .generation_done(true),
356 )
357 .into_any_element(),
358 ),
359 single_example(
360 "Waiting for Confirmation",
361 container()
362 .child(
363 ThreadItem::new("ti-2b", "Execute shell command in terminal")
364 .timestamp("12:15 AM")
365 .status(AgentThreadStatus::WaitingForConfirmation),
366 )
367 .into_any_element(),
368 ),
369 single_example(
370 "Error",
371 container()
372 .child(
373 ThreadItem::new("ti-2c", "Failed to connect to language server")
374 .timestamp("12:20 AM")
375 .status(AgentThreadStatus::Error),
376 )
377 .into_any_element(),
378 ),
379 single_example(
380 "Running Agent",
381 container()
382 .child(
383 ThreadItem::new("ti-3", "Add line numbers option to FileEditBlock")
384 .icon(IconName::AiClaude)
385 .timestamp("7:30 PM")
386 .running(true),
387 )
388 .into_any_element(),
389 ),
390 single_example(
391 "In Worktree",
392 container()
393 .child(
394 ThreadItem::new("ti-4", "Add line numbers option to FileEditBlock")
395 .icon(IconName::AiClaude)
396 .timestamp("7:37 PM")
397 .worktree("link-agent-panel"),
398 )
399 .into_any_element(),
400 ),
401 single_example(
402 "With Changes",
403 container()
404 .child(
405 ThreadItem::new("ti-5", "Managing user and project settings interactions")
406 .icon(IconName::AiClaude)
407 .timestamp("7:37 PM")
408 .added(10)
409 .removed(3),
410 )
411 .into_any_element(),
412 ),
413 single_example(
414 "Selected Item",
415 container()
416 .child(
417 ThreadItem::new("ti-6", "Refine textarea interaction behavior")
418 .icon(IconName::AiGemini)
419 .timestamp("3:00 PM")
420 .selected(true),
421 )
422 .into_any_element(),
423 ),
424 ];
425
426 Some(
427 example_group(thread_item_examples)
428 .vertical()
429 .into_any_element(),
430 )
431 }
432}