1use crate::db::Database;
4use crate::models::TodoStatus;
5use crate::models::{
6 AgentGuardrails, GitAgentContext, JjAgentContext, TodoAgentView, VcsMode, WorkflowContext,
7};
8use crate::services::agent_context::build_agent_extensions;
9use crate::services::{
10 LinkService, RepoService, ScrapService, TaskService, TodoService, WorktreeService,
11};
12use crate::use_cases::ApplyTodoActionUseCase;
13use crate::utils::TrackError;
14use crate::webui::error::WebError;
15use crate::webui::state::{AppState, SseEvent};
16use crate::webui::templates::SharedTemplates;
17use axum::{
18 extract::{Path, State},
19 response::Html,
20 Form, Json,
21};
22use serde::{Deserialize, Serialize};
23
24#[derive(Clone)]
26pub struct WebState {
27 pub app: AppState,
28 pub templates: SharedTemplates,
29}
30
31pub type AppError = WebError;
33
34#[derive(Serialize)]
36pub struct StatusResponse {
37 pub task: Option<serde_json::Value>,
38 pub todos: Vec<serde_json::Value>,
39 pub links: Vec<serde_json::Value>,
40 pub scraps: Vec<serde_json::Value>,
41 pub worktrees: Vec<serde_json::Value>,
42 pub repos: Vec<serde_json::Value>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub workflow: Option<WorkflowContext>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub vcs_mode: Option<VcsMode>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub jj: Option<JjAgentContext>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub git: Option<GitAgentContext>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub todos_agent: Option<Vec<TodoAgentView>>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub guardrails: Option<AgentGuardrails>,
55}
56
57#[derive(Deserialize)]
59pub struct AddTodoForm {
60 pub content: String,
61 #[serde(default)]
62 pub create_worktree: bool,
63}
64
65#[derive(Deserialize)]
67pub struct AddScrapForm {
68 pub content: String,
69}
70
71#[derive(Deserialize)]
73pub struct UpdateDescriptionForm {
74 pub description: String,
75}
76
77#[derive(Deserialize)]
79pub struct UpdateTicketForm {
80 pub ticket_id: String,
81 pub ticket_url: Option<String>,
82}
83
84#[derive(Deserialize)]
86pub struct AddLinkForm {
87 pub url: String,
88 pub title: Option<String>,
89}
90
91pub async fn index(State(state): State<WebState>) -> Result<Html<String>, AppError> {
93 let db = state.app.db.lock().await;
94
95 let current_task_id = match db.get_current_task_id()? {
96 Some(id) => id,
97 None => {
98 let html = state.templates.render(
99 "index.html",
100 serde_json::json!({
101 "task": null,
102 "todos": [],
103 "scraps": [],
104 "links": [],
105 "repos": [],
106 "worktrees": [],
107 }),
108 )?;
109 return Ok(Html(html));
110 }
111 };
112
113 let context = build_status_context(&db, current_task_id)?;
114 let html = state.templates.render("index.html", context)?;
115 Ok(Html(html))
116}
117
118pub async fn api_status(State(state): State<WebState>) -> Result<Json<StatusResponse>, AppError> {
120 let db = state.app.db.lock().await;
121
122 let current_task_id = match db.get_current_task_id()? {
123 Some(id) => id,
124 None => {
125 return Ok(Json(StatusResponse {
126 task: None,
127 todos: vec![],
128 links: vec![],
129 scraps: vec![],
130 worktrees: vec![],
131 repos: vec![],
132 workflow: None,
133 vcs_mode: None,
134 jj: None,
135 git: None,
136 todos_agent: None,
137 guardrails: None,
138 }));
139 }
140 };
141
142 let task_service = TaskService::new(&db);
143 let task = task_service.get_task(current_task_id)?;
144
145 let todo_service = TodoService::new(&db);
146 let todos = todo_service.list_todos(current_task_id)?;
147
148 let link_service = LinkService::new(&db);
149 let links = link_service.list_links(current_task_id)?;
150
151 let scrap_service = ScrapService::new(&db);
152 let scraps = scrap_service.list_scraps(current_task_id)?;
153
154 let worktree_service = WorktreeService::new(&db);
155 let worktrees = worktree_service.list_worktrees(current_task_id)?;
156
157 let repo_service = RepoService::new(&db);
158 let repos = repo_service.list_repos(current_task_id)?;
159
160 let vcs_mode = db.get_vcs_mode()?;
161 let agent = build_agent_extensions(
162 vcs_mode,
163 &task,
164 &todos,
165 &worktrees,
166 &repos,
167 &worktree_service,
168 );
169
170 Ok(Json(StatusResponse {
171 task: Some(serde_json::to_value(&task)?),
172 todos: format_todos(todos, &worktrees, &scraps),
173 links: links
174 .iter()
175 .map(|l| serde_json::to_value(l).unwrap_or_default())
176 .collect(),
177 scraps: scraps
178 .iter()
179 .map(|s| serde_json::to_value(s).unwrap_or_default())
180 .collect(),
181 worktrees: worktrees
182 .iter()
183 .map(|w| serde_json::to_value(w).unwrap_or_default())
184 .collect(),
185 repos: repos
186 .iter()
187 .map(|r| serde_json::to_value(r).unwrap_or_default())
188 .collect(),
189 workflow: Some(agent.workflow),
190 vcs_mode: Some(agent.vcs_mode),
191 jj: agent.jj,
192 git: agent.git,
193 todos_agent: Some(agent.todos_agent),
194 guardrails: Some(agent.guardrails),
195 }))
196}
197
198pub async fn get_description(State(state): State<WebState>) -> Result<Html<String>, AppError> {
200 let db = state.app.db.lock().await;
201 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
202
203 let task_service = TaskService::new(&db);
204 let task = task_service.get_task(current_task_id)?;
205
206 let html = state.templates.render(
207 "partials/description.html",
208 serde_json::json!({
209 "task": task,
210 }),
211 )?;
212
213 Ok(Html(html))
214}
215
216pub async fn get_ticket(State(state): State<WebState>) -> Result<Html<String>, AppError> {
218 let db = state.app.db.lock().await;
219 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
220
221 let task_service = TaskService::new(&db);
222 let task = task_service.get_task(current_task_id)?;
223
224 let html = state.templates.render(
225 "partials/ticket.html",
226 serde_json::json!({
227 "task": task,
228 }),
229 )?;
230
231 Ok(Html(html))
232}
233
234pub async fn get_links(State(state): State<WebState>) -> Result<Html<String>, AppError> {
236 let db = state.app.db.lock().await;
237 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
238
239 let link_service = LinkService::new(&db);
240 let links = link_service.list_links(current_task_id)?;
241
242 let html = state.templates.render(
243 "partials/links.html",
244 serde_json::json!({
245 "links": links,
246 }),
247 )?;
248
249 Ok(Html(html))
250}
251
252pub async fn get_repos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
254 let db = state.app.db.lock().await;
255 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
256
257 let repo_service = RepoService::new(&db);
258 let repos = repo_service.list_repos(current_task_id)?;
259
260 let html = state.templates.render(
261 "partials/repos.html",
262 serde_json::json!({
263 "repos": repos,
264 }),
265 )?;
266
267 Ok(Html(html))
268}
269
270pub async fn get_todos(State(state): State<WebState>) -> Result<Html<String>, AppError> {
272 let db = state.app.db.lock().await;
273 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
274
275 let todo_service = TodoService::new(&db);
276 let todos = todo_service.list_todos(current_task_id)?;
277
278 let scrap_service = ScrapService::new(&db);
279 let scraps = scrap_service.list_scraps(current_task_id)?;
280
281 let worktree_service = WorktreeService::new(&db);
282 let worktrees = worktree_service.list_worktrees(current_task_id)?;
283
284 let html = state.templates.render(
285 "partials/todo_list.html",
286 serde_json::json!({
287 "todos": format_todos(todos, &worktrees, &scraps),
288 }),
289 )?;
290
291 Ok(Html(html))
292}
293
294pub async fn get_scraps(State(state): State<WebState>) -> Result<Html<String>, AppError> {
296 let db = state.app.db.lock().await;
297 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
298
299 let scrap_service = ScrapService::new(&db);
300 let scraps = scrap_service.list_scraps(current_task_id)?;
301
302 let html = state.templates.render(
303 "partials/scrap_list.html",
304 serde_json::json!({
305 "scraps": format_scraps(&scraps),
306 }),
307 )?;
308
309 Ok(Html(html))
310}
311
312pub async fn add_todo(
314 State(state): State<WebState>,
315 Form(form): Form<AddTodoForm>,
316) -> Result<Html<String>, AppError> {
317 let db = state.app.db.lock().await;
318
319 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
320
321 let todo_service = TodoService::new(&db);
322 let _todo = todo_service.add_todo(current_task_id, &form.content, form.create_worktree)?;
323
324 state.app.broadcast(SseEvent::Todos);
326
327 let todos = todo_service.list_todos(current_task_id)?;
329 let scrap_service = ScrapService::new(&db);
330 let scraps = scrap_service.list_scraps(current_task_id)?;
331 let worktree_service = WorktreeService::new(&db);
332 let worktrees = worktree_service.list_worktrees(current_task_id)?;
333
334 let html = state.templates.render(
335 "partials/todo_list.html",
336 serde_json::json!({
337 "todos": format_todos(todos, &worktrees, &scraps),
338 }),
339 )?;
340
341 Ok(Html(html))
342}
343
344pub async fn update_todo_status(
346 State(state): State<WebState>,
347 Path((todo_index, new_status)): Path<(i64, String)>,
348) -> Result<Html<String>, AppError> {
349 let db = state.app.db.lock().await;
350
351 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
352
353 let todo_service = TodoService::new(&db);
354 let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
355
356 if new_status.as_str() == TodoStatus::PENDING {
357 todo_service.update_status(todo.id, &new_status)?;
358 } else {
359 let action = crate::models::TodoAction::from_web_route(&new_status)?;
360 ApplyTodoActionUseCase::new(&db).execute(current_task_id, todo_index, action)?;
361 }
362
363 state.app.broadcast(SseEvent::Todos);
365
366 let todos = todo_service.list_todos(current_task_id)?;
368 let scrap_service = ScrapService::new(&db);
369 let scraps = scrap_service.list_scraps(current_task_id)?;
370 let worktree_service = WorktreeService::new(&db);
371 let worktrees = worktree_service.list_worktrees(current_task_id)?;
372
373 let html = state.templates.render(
374 "partials/todo_list.html",
375 serde_json::json!({
376 "todos": format_todos(todos, &worktrees, &scraps),
377 }),
378 )?;
379
380 Ok(Html(html))
381}
382
383pub async fn delete_todo(
385 State(state): State<WebState>,
386 Path(todo_index): Path<i64>,
387) -> Result<Html<String>, AppError> {
388 let db = state.app.db.lock().await;
389
390 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
391
392 let todo_service = TodoService::new(&db);
393 let todo = todo_service.get_todo_by_index(current_task_id, todo_index)?;
394 todo_service.delete_todo(todo.id)?;
395
396 state.app.broadcast(SseEvent::Todos);
398
399 let todos = todo_service.list_todos(current_task_id)?;
401 let scrap_service = ScrapService::new(&db);
402 let scraps = scrap_service.list_scraps(current_task_id)?;
403 let worktree_service = WorktreeService::new(&db);
404 let worktrees = worktree_service.list_worktrees(current_task_id)?;
405
406 let html = state.templates.render(
407 "partials/todo_list.html",
408 serde_json::json!({
409 "todos": format_todos(todos, &worktrees, &scraps),
410 }),
411 )?;
412
413 Ok(Html(html))
414}
415
416pub async fn move_todo_to_next(
418 State(state): State<WebState>,
419 Path(todo_index): Path<i64>,
420) -> Result<Html<String>, AppError> {
421 let db = state.app.db.lock().await;
422
423 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
424
425 let todo_service = TodoService::new(&db);
426 todo_service.move_to_next(current_task_id, todo_index)?;
427
428 state.app.broadcast(SseEvent::Todos);
430
431 let todos = todo_service.list_todos(current_task_id)?;
433 let scrap_service = ScrapService::new(&db);
434 let scraps = scrap_service.list_scraps(current_task_id)?;
435 let worktree_service = WorktreeService::new(&db);
436 let worktrees = worktree_service.list_worktrees(current_task_id)?;
437
438 let html = state.templates.render(
439 "partials/todo_list.html",
440 serde_json::json!({
441 "todos": format_todos(todos, &worktrees, &scraps),
442 }),
443 )?;
444
445 Ok(Html(html))
446}
447
448pub async fn add_scrap(
450 State(state): State<WebState>,
451 Form(form): Form<AddScrapForm>,
452) -> Result<Html<String>, AppError> {
453 let db = state.app.db.lock().await;
454
455 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
456
457 let scrap_service = ScrapService::new(&db);
458 let _scrap = scrap_service.add_scrap(current_task_id, &form.content)?;
459
460 state.app.broadcast(SseEvent::Scraps);
462
463 let scraps = scrap_service.list_scraps(current_task_id)?;
465 let html = state.templates.render(
466 "partials/scrap_list.html",
467 serde_json::json!({
468 "scraps": format_scraps(&scraps),
469 }),
470 )?;
471
472 Ok(Html(html))
473}
474
475pub async fn update_description(
477 State(state): State<WebState>,
478 Form(form): Form<UpdateDescriptionForm>,
479) -> Result<Html<String>, AppError> {
480 let db = state.app.db.lock().await;
481
482 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
483
484 let task_service = TaskService::new(&db);
485 task_service.set_description(current_task_id, &form.description)?;
486
487 let task = task_service.get_task(current_task_id)?;
489
490 state.app.broadcast(SseEvent::Description);
492
493 let html = state.templates.render(
495 "partials/description.html",
496 serde_json::json!({
497 "task": task,
498 }),
499 )?;
500
501 Ok(Html(html))
502}
503
504fn build_status_context(db: &Database, task_id: i64) -> anyhow::Result<serde_json::Value> {
506 let task_service = TaskService::new(db);
507 let task = task_service.get_task(task_id)?;
508
509 let todo_service = TodoService::new(db);
510 let todos = todo_service.list_todos(task_id)?;
511
512 let link_service = LinkService::new(db);
513 let links = link_service.list_links(task_id)?;
514
515 let scrap_service = ScrapService::new(db);
516 let scraps = scrap_service.list_scraps(task_id)?;
517
518 let worktree_service = WorktreeService::new(db);
519 let worktrees = worktree_service.list_worktrees(task_id)?;
520
521 let repo_service = RepoService::new(db);
522 let repos = repo_service.list_repos(task_id)?;
523
524 let base_branch = if let Some(base_wt) = worktrees.iter().find(|wt| wt.is_base) {
526 base_wt.branch.clone()
527 } else if let Some(ref ticket_id) = task.ticket_id {
528 format!("task/{}", ticket_id)
529 } else {
530 format!("task/task-{}", task.id)
531 };
532
533 let calendar_id = db.get_app_state("calendar_id").ok().flatten();
535
536 Ok(serde_json::json!({
537 "task": task,
538 "todos": format_todos(todos, &worktrees, &scraps),
539 "links": links,
540 "scraps": format_scraps(&scraps),
541 "worktrees": worktrees,
542 "repos": repos,
543 "base_branch": base_branch,
544 "calendar_id": calendar_id,
545 }))
546}
547
548fn format_todos(
550 todos: Vec<crate::models::Todo>,
551 worktrees: &[crate::models::Worktree],
552 scraps: &[crate::models::Scrap],
553) -> Vec<serde_json::Value> {
554 let oldest_pending_id = todos
556 .iter()
557 .filter(|t| t.status == TodoStatus::Pending)
558 .min_by_key(|t| t.task_index)
559 .map(|t| t.id);
560
561 todos
562 .into_iter()
563 .map(|todo| {
564 let todo_worktrees: Vec<String> = worktrees
565 .iter()
566 .filter(|wt| wt.todo_id == Some(todo.id))
567 .map(|wt| wt.path.clone())
568 .collect();
569
570 let scrap_count = scraps
572 .iter()
573 .filter(|s| s.active_todo_id == Some(todo.task_index))
574 .count();
575
576 let is_in_progress = oldest_pending_id == Some(todo.id);
578
579 let mut value = serde_json::to_value(&todo).unwrap_or_default();
580
581 if let Some(obj) = value.as_object_mut() {
582 obj.insert(
584 "worktree_requested".to_string(),
585 serde_json::Value::Bool(todo.worktree_requested),
586 );
587 obj.insert(
588 "worktree_paths".to_string(),
589 serde_json::json!(todo_worktrees),
590 );
591 obj.insert(
592 "content_html".to_string(),
593 serde_json::Value::String(todo.content_html()),
594 );
595 obj.insert(
596 "has_scraps".to_string(),
597 serde_json::Value::Bool(scrap_count > 0),
598 );
599 obj.insert(
600 "is_in_progress".to_string(),
601 serde_json::Value::Bool(is_in_progress),
602 );
603 }
604 value
605 })
606 .collect()
607}
608
609fn format_scraps(scraps: &[crate::models::Scrap]) -> Vec<serde_json::Value> {
611 use chrono::Local;
612
613 scraps
614 .iter()
615 .map(|scrap| {
616 let formatted_time = scrap
617 .created_at
618 .with_timezone(&Local)
619 .format("%Y-%m-%d %H:%M:%S")
620 .to_string();
621
622 serde_json::json!({
623 "scrap_id": scrap.scrap_id,
624 "content": scrap.content,
625 "content_html": scrap.content_html(),
626 "created_at": formatted_time,
627 "active_todo_id": scrap.active_todo_id,
628 })
629 })
630 .collect()
631}
632
633pub async fn update_ticket(
635 State(state): State<WebState>,
636 Form(form): Form<UpdateTicketForm>,
637) -> Result<Html<String>, AppError> {
638 let db = state.app.db.lock().await;
639
640 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
641
642 let task_service = TaskService::new(&db);
643
644 let ticket_url = form.ticket_url.filter(|url| !url.trim().is_empty());
646 let ticket_url_str = ticket_url.as_deref().unwrap_or("");
647
648 task_service.link_ticket(current_task_id, &form.ticket_id, ticket_url_str)?;
649
650 let task = task_service.get_task(current_task_id)?;
652
653 state.app.broadcast(SseEvent::Ticket);
655
656 let html = state.templates.render(
658 "partials/ticket.html",
659 serde_json::json!({
660 "task": task,
661 }),
662 )?;
663
664 Ok(Html(html))
665}
666
667pub async fn add_link(
669 State(state): State<WebState>,
670 Form(form): Form<AddLinkForm>,
671) -> Result<Html<String>, AppError> {
672 let db = state.app.db.lock().await;
673
674 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
675
676 let link_service = LinkService::new(&db);
677
678 let title = form.title.filter(|t| !t.trim().is_empty());
680
681 link_service.add_link(current_task_id, &form.url, title.as_deref())?;
682
683 state.app.broadcast(SseEvent::Links);
685
686 let links = link_service.list_links(current_task_id)?;
688 let html = state.templates.render(
689 "partials/links.html",
690 serde_json::json!({
691 "links": links,
692 }),
693 )?;
694
695 Ok(Html(html))
696}
697
698pub async fn delete_link(
700 State(state): State<WebState>,
701 Path(link_index): Path<i64>,
702) -> Result<Html<String>, AppError> {
703 let db = state.app.db.lock().await;
704
705 let current_task_id = db.get_current_task_id()?.ok_or(TrackError::NoActiveTask)?;
706
707 let link_service = LinkService::new(&db);
708 let links = link_service.list_links(current_task_id)?;
709
710 let link = links
712 .iter()
713 .find(|l| l.task_index == link_index)
714 .ok_or_else(|| TrackError::Other(format!("Link #{} not found", link_index)))?;
715
716 link_service.delete_link(link.id)?;
718
719 state.app.broadcast(SseEvent::Links);
721
722 let links = link_service.list_links(current_task_id)?;
724 let html = state.templates.render(
725 "partials/links.html",
726 serde_json::json!({
727 "links": links,
728 }),
729 )?;
730
731 Ok(Html(html))
732}