commit b9619e08968587b3614a4bbcf44bc5808f889e97
Author: Coder Agent <coder@agents.omni>
Date: Thu Feb 19 13:49:49 2026
Studio tasks: compare parity with task web and roadmap
Task-Id: t-460
diff --git a/Omni/Agentd/Studio.hs b/Omni/Agentd/Studio.hs
index e25f7bf8..f5f9f13a 100755
--- a/Omni/Agentd/Studio.hs
+++ b/Omni/Agentd/Studio.hs
@@ -94,7 +94,14 @@ type API =
:<|> "notes" :> QueryParam "q" Text :> QueryParam "topic" Text :> Get '[Lucid.HTML] (Lucid.Html ())
:<|> "notes" :> Capture "noteId" Int :> Get '[Lucid.HTML] (Lucid.Html ())
-- Tasks
- :<|> "tasks" :> QueryParam "q" Text :> QueryParam "status" Text :> Get '[Lucid.HTML] (Lucid.Html ())
+ :<|> "tasks"
+ :> QueryParam "q" Text
+ :> QueryParam "status" Text
+ :> QueryParam "priority" Text
+ :> QueryParam "namespace" Text
+ :> QueryParam "type" Text
+ :> QueryParam "sort" Text
+ :> Get '[Lucid.HTML] (Lucid.Html ())
:<|> "tasks" :> Capture "taskId" Text :> Get '[Lucid.HTML] (Lucid.Html ())
-- Health
:<|> "health" :> Get '[PlainText] Text
diff --git a/Omni/Agentd/Studio/TASKS_PARITY_REPORT.md b/Omni/Agentd/Studio/TASKS_PARITY_REPORT.md
new file mode 100644
index 00000000..29c4ec67
--- /dev/null
+++ b/Omni/Agentd/Studio/TASKS_PARITY_REPORT.md
@@ -0,0 +1,46 @@
+# Studio Tasks vs task web: Feature comparison and improvement plan
+
+## Feature comparison
+
+| Feature | task web | Studio (after this change) |
+|---|---|---|
+| Task list view | Yes | Yes |
+| Task detail view | Yes | Yes |
+| Create task | No UI | No UI |
+| Edit task | Yes (status/priority/complexity/description) | No |
+| Task comments | Yes (timeline + add comment) | Read-only |
+| Epic/subtask tree | Yes | Partial (parent + children) |
+| Status filtering | Yes | Yes |
+| Priority display | Yes | Yes |
+| Search | Partial (filter-driven) | Yes (ID/title/description/namespace/parent) |
+| Markdown rendering | Yes | Yes |
+| Mobile responsive | Yes | Partial |
+
+## Gaps observed
+
+1. Studio still lacks task mutation flows (create/edit/update/comment).
+2. Studio does not have dedicated ready/blocked/intervention task views.
+3. Epic navigation is improved but not a full tree explorer.
+4. Task styling and interaction patterns are still less polished than task web.
+
+## Prioritized improvements
+
+1. **P0 — Add create/edit flows in Studio tasks**
+ - Scope: create task form, update status/priority/complexity/description.
+ - Effort: medium (~1-2 days).
+
+2. **P0 — Add comment authoring in Studio detail view**
+ - Scope: add-comment form and persisted comment posting.
+ - Effort: medium (~1 day).
+
+3. **P1 — Add queue-focused pages**
+ - Scope: ready queue, blocked queue, needs-human-action queue.
+ - Effort: medium (~1 day).
+
+4. **P1 — Add epic/subtask tree explorer**
+ - Scope: expandable hierarchy + parent/child navigation aids.
+ - Effort: medium (~1 day).
+
+5. **P2 — Align Studio task components/style with task web**
+ - Scope: shared badges/forms/layout patterns and responsive polish.
+ - Effort: low-medium (~0.5-1 day).
diff --git a/Omni/Agentd/Studio/Tasks.hs b/Omni/Agentd/Studio/Tasks.hs
index aeb40bbc..55318ce6 100644
--- a/Omni/Agentd/Studio/Tasks.hs
+++ b/Omni/Agentd/Studio/Tasks.hs
@@ -1,7 +1,8 @@
+{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE NoImplicitPrelude #-}
--- | Studio Tasks Page - task dashboard
+-- | Studio Tasks Page - richer task dashboard with parity roadmap.
--
-- : out studio-tasks
-- : dep lucid
@@ -14,166 +15,450 @@ module Omni.Agentd.Studio.Tasks
where
import Alpha
+import qualified Data.List as List
import qualified Data.Text as Text
+import Data.Time (UTCTime)
+import qualified Data.Time.Format as Time
import qualified Lucid
import qualified Omni.Agentd.Studio.Layout as Layout
import qualified Omni.Task.Core as Task
+import Omni.Task.Web.Components (renderMarkdown)
import Servant
--- | Tasks page
-tasksPage :: Maybe Text -> Maybe Text -> Servant.Handler (Lucid.Html ())
-tasksPage maybeQuery maybeStatus = do
+-- | Tasks page.
+tasksPage ::
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Servant.Handler (Lucid.Html ())
+tasksPage maybeQuery maybeStatus maybePriority maybeNamespace maybeType maybeSort = do
allTasks <- liftIO Task.loadTasks
- let filtered = filterTasks allTasks maybeQuery maybeStatus
+ let filtered = applyFilters allTasks maybeQuery maybeStatus maybePriority maybeNamespace maybeType
+ sorted = sortTasks maybeSort filtered
statusCounts = countByStatus allTasks
- pure <| Layout.pageHtml Layout.Page
- { Layout.pageTitle = "Tasks"
- , Layout.pageTab = Layout.NavTasks
- , Layout.pageBody = do
- Lucid.h1_ [Lucid.class_ "mb-4"] "Task Dashboard"
- statsBar statusCounts
- searchForm maybeQuery maybeStatus
- if null filtered
- then Lucid.div_ [Lucid.class_ "empty"] "No tasks found"
- else do
- Lucid.p_ [Lucid.class_ "text-muted text-small mb-4"] <|
- Lucid.toHtml ("Showing " <> tshow (length filtered) <> " tasks")
- tasksTable filtered
- }
-
--- | Stats bar showing counts by status
-statsBar :: [(Text, Int)] -> Lucid.Html ()
-statsBar counts = Lucid.div_ [Lucid.class_ "search-bar", Lucid.style_ "margin-bottom: 16px"] <| do
- forM_ counts <| \(status, count) -> do
- let badgeClass = case status of
- "Open" -> "badge badge-running"
- "InProgress" -> "badge badge-running"
- "Done" -> "badge badge-done"
- "NeedsHelp" -> "badge badge-error"
- _ -> "badge"
- Lucid.a_ [Lucid.href_ ("/tasks?status=" <> status), Lucid.style_ "margin-right: 12px; text-decoration: none"] <| do
- Lucid.span_ [Lucid.class_ badgeClass] <| Lucid.toHtml (status <> ": " <> tshow count)
-
--- | Search form
-searchForm :: Maybe Text -> Maybe Text -> Lucid.Html ()
-searchForm query status =
+ pure
+ <| Layout.pageHtml
+ Layout.Page
+ { Layout.pageTitle = "Tasks",
+ Layout.pageTab = Layout.NavTasks,
+ Layout.pageBody = do
+ Lucid.h1_ [Lucid.class_ "mb-4"] "Task Dashboard"
+ Lucid.p_ [Lucid.class_ "text-muted mb-4"] "Studio now includes a richer task index and detail view plus a parity roadmap versus task web."
+
+ parityCard
+ improvementPlanCard
+ statsBar statusCounts
+ searchForm maybeQuery maybeStatus maybePriority maybeNamespace maybeType maybeSort
+
+ if null sorted
+ then Lucid.div_ [Lucid.class_ "empty"] "No tasks found"
+ else do
+ Lucid.p_ [Lucid.class_ "text-muted text-small mb-4"] <|
+ Lucid.toHtml ("Showing " <> tshow (length sorted) <> " tasks")
+ tasksTable sorted
+ }
+
+parityCard :: Lucid.Html ()
+parityCard =
+ Lucid.div_ [Lucid.class_ "card"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Feature parity snapshot: task web vs Studio"
+ Lucid.table_ [Lucid.class_ "table"] <| do
+ Lucid.thead_ <| Lucid.tr_ <| do
+ Lucid.th_ "Feature"
+ Lucid.th_ "task web"
+ Lucid.th_ "Studio"
+ Lucid.tbody_ <| do
+ row "Task list view" "Yes" "Yes"
+ row "Task detail view" "Yes" "Yes"
+ row "Create task" "No" "No"
+ row "Edit task" "Yes (status/priority/complexity/description)" "No"
+ row "Task comments" "Yes (timeline + add comment)" "Read-only"
+ row "Epic/subtask tree" "Yes" "Partial (parent + children)"
+ row "Status filtering" "Yes" "Yes"
+ row "Priority display" "Yes" "Yes"
+ row "Search" "Partial" "Yes"
+ row "Markdown rendering" "Yes" "Yes"
+ row "Mobile responsive" "Yes" "Partial"
+ where
+ row feature web studio = Lucid.tr_ <| do
+ Lucid.td_ (Lucid.toHtml feature)
+ Lucid.td_ (Lucid.toHtml web)
+ Lucid.td_ (Lucid.toHtml studio)
+
+improvementPlanCard :: Lucid.Html ()
+improvementPlanCard =
+ Lucid.div_ [Lucid.class_ "card"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Prioritized improvement plan"
+ Lucid.ol_ [Lucid.style_ "padding-left: 20px; margin: 0;"] <| do
+ item "P0" "Add in-Studio task creation/edit flows" "High impact, medium effort (~1-2 days)"
+ item "P0" "Support comment authoring and status/priority edits in detail" "High impact, medium effort (~1 day)"
+ item "P1" "Add dedicated views for ready/blocked/intervention queues" "Medium impact, medium effort (~1 day)"
+ item "P1" "Add reusable task tree component for epic/subtask navigation" "Medium impact, medium effort (~1 day)"
+ item "P2" "Align Studio task styling/components with task web for consistency" "Medium impact, low-medium effort (~0.5-1 day)"
+ where
+ item pri label effort =
+ Lucid.li_ [Lucid.style_ "margin-bottom: 10px;"] <| do
+ Lucid.span_ [Lucid.class_ "badge", Lucid.style_ "margin-right: 8px;"] (Lucid.toHtml pri)
+ Lucid.span_ (Lucid.toHtml label)
+ Lucid.div_ [Lucid.class_ "text-small", Lucid.style_ "margin-top: 4px;"] (Lucid.toHtml effort)
+
+-- | Stats bar showing counts by status.
+statsBar :: [(Task.Status, Int)] -> Lucid.Html ()
+statsBar counts =
+ Lucid.div_ [Lucid.class_ "search-bar", Lucid.style_ "margin-bottom: 16px"] <| do
+ forM_ counts <| \(statusVal, count) -> do
+ let statusText = tshow statusVal
+ Lucid.a_
+ [ Lucid.href_ ("/tasks?status=" <> statusText),
+ Lucid.style_ "margin-right: 10px; text-decoration: none"
+ ]
+ <| Lucid.span_ [Lucid.class_ (statusBadgeClass statusVal)] <|
+ Lucid.toHtml (statusLabel statusVal <> ": " <> tshow count)
+
+-- | Search/filter form.
+searchForm ::
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Lucid.Html ()
+searchForm maybeQuery maybeStatus maybePriority maybeNamespace maybeType maybeSort =
Lucid.form_ [Lucid.method_ "get", Lucid.action_ "/tasks", Lucid.class_ "search-bar"] <| do
Lucid.input_
- [ Lucid.class_ "search-input"
- , Lucid.name_ "q"
- , Lucid.placeholder_ "Search tasks..."
- , Lucid.value_ (fromMaybe "" query)
+ [ Lucid.class_ "search-input",
+ Lucid.name_ "q",
+ Lucid.placeholder_ "Search by ID, title, description...",
+ Lucid.value_ (fromMaybe "" maybeQuery)
]
- Lucid.select_ [Lucid.class_ "search-input", Lucid.name_ "status", Lucid.style_ "max-width: 150px"] <| do
+
+ Lucid.select_ [Lucid.class_ "search-input", Lucid.name_ "status", Lucid.style_ "max-width: 190px"] <| do
Lucid.option_ [Lucid.value_ ""] "All statuses"
- forM_ ["Open", "InProgress", "Done", "NeedsHelp"] <| \s ->
- if Just s == status
- then Lucid.option_ [Lucid.value_ s, Lucid.selected_ "selected"] (Lucid.toHtml s)
- else Lucid.option_ [Lucid.value_ s] (Lucid.toHtml s)
- Lucid.button_ [Lucid.class_ "btn", Lucid.type_ "submit"] "Search"
+ forM_ allStatuses <| \s ->
+ Lucid.option_
+ ([Lucid.value_ (tshow s)] <> selectedWhen (Just (tshow s) == maybeStatus))
+ (Lucid.toHtml (statusLabel s))
+
+ Lucid.select_ [Lucid.class_ "search-input", Lucid.name_ "priority", Lucid.style_ "max-width: 140px"] <| do
+ Lucid.option_ [Lucid.value_ ""] "All priorities"
+ forM_ allPriorities <| \p ->
+ Lucid.option_
+ ([Lucid.value_ (tshow p)] <> selectedWhen (Just (tshow p) == maybePriority))
+ (Lucid.toHtml (tshow p))
+
+ Lucid.select_ [Lucid.class_ "search-input", Lucid.name_ "type", Lucid.style_ "max-width: 130px"] <| do
+ Lucid.option_ [Lucid.value_ ""] "All types"
+ Lucid.option_ ([Lucid.value_ "task"] <> selectedWhen (maybeType == Just "task")) "Task"
+ Lucid.option_ ([Lucid.value_ "epic"] <> selectedWhen (maybeType == Just "epic")) "Epic"
+
+ Lucid.input_
+ [ Lucid.class_ "search-input",
+ Lucid.name_ "namespace",
+ Lucid.placeholder_ "Namespace",
+ Lucid.value_ (fromMaybe "" maybeNamespace),
+ Lucid.style_ "max-width: 220px"
+ ]
--- | Tasks table
+ Lucid.select_ [Lucid.class_ "search-input", Lucid.name_ "sort", Lucid.style_ "max-width: 220px"] <| do
+ optionSort "updated" "Recently updated"
+ optionSort "created-desc" "Newest created"
+ optionSort "created-asc" "Oldest created"
+ optionSort "priority-high" "Priority high → low"
+ optionSort "priority-low" "Priority low → high"
+ optionSort "status" "Status"
+
+ Lucid.button_ [Lucid.class_ "btn", Lucid.type_ "submit"] "Filter"
+ Lucid.a_ [Lucid.class_ "btn btn-secondary", Lucid.href_ "/tasks"] "Clear"
+ where
+ selectedWhen cond = [Lucid.selected_ "selected" | cond]
+ optionSort val label =
+ Lucid.option_ ([Lucid.value_ val] <> selectedWhen (fromMaybe "updated" maybeSort == val)) (Lucid.toHtml label)
+
+-- | Tasks table.
tasksTable :: [Task.Task] -> Lucid.Html ()
-tasksTable tasks = Lucid.table_ [Lucid.class_ "table"] <| do
- Lucid.thead_ <| Lucid.tr_ <| do
- Lucid.th_ "ID"
- Lucid.th_ "Title"
- Lucid.th_ "Status"
- Lucid.th_ "Priority"
- Lucid.th_ "Namespace"
- Lucid.tbody_ <| forM_ tasks renderTaskRow
-
--- | Render task row
+tasksTable tasks =
+ Lucid.table_ [Lucid.class_ "table"] <| do
+ Lucid.thead_ <| Lucid.tr_ <| do
+ Lucid.th_ "ID"
+ Lucid.th_ "Title"
+ Lucid.th_ "Type"
+ Lucid.th_ "Status"
+ Lucid.th_ "Priority"
+ Lucid.th_ "Namespace"
+ Lucid.th_ "Updated"
+ Lucid.tbody_ <| forM_ tasks renderTaskRow
+
+-- | Render task row.
renderTaskRow :: Task.Task -> Lucid.Html ()
-renderTaskRow t = Lucid.tr_ <| do
- Lucid.td_ <| Lucid.a_ [Lucid.href_ ("/tasks/" <> Task.taskId t)]
- (Lucid.toHtml <| Task.taskId t)
- Lucid.td_ <| Lucid.toHtml (Task.taskTitle t)
- Lucid.td_ <| statusBadge (Task.taskStatus t)
- Lucid.td_ <| priorityBadge (Task.taskPriority t)
- Lucid.td_ [Lucid.class_ "text-muted text-small"] <|
- Lucid.toHtml (fromMaybe "-" <| Task.taskNamespace t)
-
--- | Status badge
+renderTaskRow t =
+ Lucid.tr_ <| do
+ Lucid.td_ <| Lucid.a_ [Lucid.href_ ("/tasks/" <> Task.taskId t)] (Lucid.toHtml (Task.taskId t))
+ Lucid.td_ (Lucid.toHtml (Task.taskTitle t))
+ Lucid.td_ [Lucid.class_ "text-muted text-small"] (Lucid.toHtml (taskTypeLabel (Task.taskType t)))
+ Lucid.td_ (statusBadge (Task.taskStatus t))
+ Lucid.td_ (priorityBadge (Task.taskPriority t))
+ Lucid.td_ [Lucid.class_ "text-muted text-small"] (Lucid.toHtml (fromMaybe "-" (Task.taskNamespace t)))
+ Lucid.td_ [Lucid.class_ "text-muted text-small"] (Lucid.toHtml (formatTimestamp (Task.taskUpdatedAt t)))
+
+-- | Status badge.
statusBadge :: Task.Status -> Lucid.Html ()
-statusBadge status = case status of
- Task.Open -> Lucid.span_ [Lucid.class_ "badge badge-running"] "Open"
- Task.InProgress -> Lucid.span_ [Lucid.class_ "badge badge-running"] "In Progress"
- Task.Done -> Lucid.span_ [Lucid.class_ "badge badge-done"] "Done"
- Task.NeedsHelp -> Lucid.span_ [Lucid.class_ "badge badge-error"] "Needs Help"
- Task.Draft -> Lucid.span_ [Lucid.class_ "badge"] "Draft"
- Task.Review -> Lucid.span_ [Lucid.class_ "badge badge-running"] "Review"
- Task.Approved -> Lucid.span_ [Lucid.class_ "badge badge-done"] "Approved"
-
--- | Priority badge
+statusBadge statusVal = Lucid.span_ [Lucid.class_ (statusBadgeClass statusVal)] (Lucid.toHtml (statusLabel statusVal))
+
+statusBadgeClass :: Task.Status -> Text
+statusBadgeClass = \case
+ Task.Open -> "badge badge-open"
+ Task.Draft -> "badge"
+ Task.InProgress -> "badge badge-running"
+ Task.Review -> "badge badge-running"
+ Task.ReviewInProgress -> "badge badge-running"
+ Task.Approved -> "badge badge-done"
+ Task.Integrating -> "badge badge-running"
+ Task.Verified -> "badge badge-done"
+ Task.Done -> "badge badge-done"
+ Task.NeedsHelp -> "badge badge-error"
+
+statusLabel :: Task.Status -> Text
+statusLabel = \case
+ Task.InProgress -> "In Progress"
+ Task.ReviewInProgress -> "Review In Progress"
+ Task.NeedsHelp -> "Needs Help"
+ Task.Open -> "Open"
+ Task.Draft -> "Draft"
+ Task.Review -> "Review"
+ Task.Approved -> "Approved"
+ Task.Integrating -> "Integrating"
+ Task.Verified -> "Verified"
+ Task.Done -> "Done"
+
+-- | Priority badge.
priorityBadge :: Task.Priority -> Lucid.Html ()
-priorityBadge pri = case pri of
- Task.P0 -> Lucid.span_ [Lucid.class_ "badge badge-error"] "P0"
- Task.P1 -> Lucid.span_ [Lucid.class_ "badge badge-error", Lucid.style_ "background: #da3633"] "P1"
- Task.P2 -> Lucid.span_ [Lucid.class_ "badge badge-running"] "P2"
- Task.P3 -> Lucid.span_ [Lucid.class_ "badge"] "P3"
- Task.P4 -> Lucid.span_ [Lucid.class_ "badge"] "P4"
-
--- | Task detail page
+priorityBadge pri =
+ case pri of
+ Task.P0 -> Lucid.span_ [Lucid.class_ "badge badge-error"] "P0"
+ Task.P1 -> Lucid.span_ [Lucid.class_ "badge badge-error", Lucid.style_ "background: #da3633"] "P1"
+ Task.P2 -> Lucid.span_ [Lucid.class_ "badge badge-running"] "P2"
+ Task.P3 -> Lucid.span_ [Lucid.class_ "badge"] "P3"
+ Task.P4 -> Lucid.span_ [Lucid.class_ "badge"] "P4"
+
+-- | Task detail page.
taskDetailPage :: Text -> Servant.Handler (Lucid.Html ())
taskDetailPage taskIdVal = do
allTasks <- liftIO Task.loadTasks
let maybeTask = Task.findTask taskIdVal allTasks
- pure <| Layout.pageHtml Layout.Page
- { Layout.pageTitle = "Task Detail"
- , Layout.pageTab = Layout.NavTasks
- , Layout.pageBody = case maybeTask of
- Nothing -> do
- Lucid.h1_ "Task Not Found"
- Lucid.p_ [Lucid.class_ "text-muted"] <| Lucid.toHtml ("ID: " <> taskIdVal)
- Lucid.a_ [Lucid.href_ "/tasks"] "← Back to tasks"
- Just t -> do
- Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "text-muted"] "← Back to tasks"
- Lucid.h1_ [Lucid.class_ "mt-4 mb-4"] <| do
- statusBadge (Task.taskStatus t)
- Lucid.span_ [Lucid.style_ "margin-left: 12px"] (Lucid.toHtml <| Task.taskTitle t)
- Lucid.div_ [Lucid.class_ "card"] <| do
- Lucid.div_ [Lucid.class_ "card-title"] "Description"
- case Task.taskDescription t of
- "" -> Lucid.p_ [Lucid.class_ "text-muted"] "(No description)"
- desc -> Lucid.pre_ [Lucid.style_ "white-space: pre-wrap"] (Lucid.toHtml desc)
- Lucid.div_ [Lucid.class_ "card mt-4"] <| do
- Lucid.div_ [Lucid.class_ "card-title"] "Details"
- Lucid.table_ [Lucid.class_ "table"] <| do
- metaRow "ID" (Task.taskId t)
- metaRow "Status" (tshow <| Task.taskStatus t)
- metaRow "Priority" (tshow <| Task.taskPriority t)
- metaRow "Type" (tshow <| Task.taskType t)
- metaRow "Namespace" (fromMaybe "-" <| Task.taskNamespace t)
- metaRow "Parent" (fromMaybe "-" <| Task.taskParent t)
- metaRow "Created" (tshow <| Task.taskCreatedAt t)
- }
+ childrenFor tid = filter (maybe False (Task.matchesId tid) <. Task.taskParent) allTasks
+ pure
+ <| Layout.pageHtml
+ Layout.Page
+ { Layout.pageTitle = "Task Detail",
+ Layout.pageTab = Layout.NavTasks,
+ Layout.pageBody = case maybeTask of
+ Nothing -> do
+ Lucid.h1_ "Task Not Found"
+ Lucid.p_ [Lucid.class_ "text-muted"] <| Lucid.toHtml ("ID: " <> taskIdVal)
+ Lucid.a_ [Lucid.href_ "/tasks"] "← Back to tasks"
+ Just t -> do
+ let children = childrenFor (Task.taskId t)
+
+ Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "text-muted"] "← Back to tasks"
+ Lucid.h1_ [Lucid.class_ "mt-4 mb-4"] <| do
+ statusBadge (Task.taskStatus t)
+ Lucid.span_ [Lucid.style_ "margin-left: 12px"] (Lucid.toHtml (Task.taskTitle t))
+
+ Lucid.div_ [Lucid.class_ "card"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Description"
+ if Text.null (Text.strip (Task.taskDescription t))
+ then Lucid.p_ [Lucid.class_ "text-muted"] "(No description)"
+ else Lucid.div_ [Lucid.class_ "markdown-content"] (renderMarkdown (Task.taskDescription t))
+
+ Lucid.div_ [Lucid.class_ "card mt-4"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Details"
+ Lucid.table_ [Lucid.class_ "table"] <| do
+ metaRow "ID" (Task.taskId t)
+ metaRow "Status" (statusLabel (Task.taskStatus t))
+ metaRow "Priority" (tshow (Task.taskPriority t))
+ metaRow "Type" (taskTypeLabel (Task.taskType t))
+ metaRow "Namespace" (fromMaybe "-" (Task.taskNamespace t))
+ metaRow "Parent" (fromMaybe "-" (Task.taskParent t))
+ metaRow "Created" (formatTimestamp (Task.taskCreatedAt t))
+ metaRow "Updated" (formatTimestamp (Task.taskUpdatedAt t))
+
+ unless (null (Task.taskDependencies t)) <| do
+ Lucid.div_ [Lucid.class_ "card mt-4"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Dependencies"
+ Lucid.ul_ <| do
+ forM_ (Task.taskDependencies t) <| \dep ->
+ Lucid.li_ <| do
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> Task.depId dep)] (Lucid.toHtml (Task.depId dep))
+ Lucid.span_ [Lucid.class_ "text-muted", Lucid.style_ "margin-left: 8px;"]
+ (Lucid.toHtml ("[" <> tshow (Task.depType dep) <> "]"))
+
+ unless (null children) <| do
+ Lucid.div_ [Lucid.class_ "card mt-4"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Child tasks"
+ Lucid.ul_ <| do
+ forM_ children <| \child ->
+ Lucid.li_ <| do
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> Task.taskId child)] (Lucid.toHtml (Task.taskId child))
+ Lucid.span_ [Lucid.style_ "margin-left: 8px;"] (Lucid.toHtml (Task.taskTitle child))
+
+ Lucid.div_ [Lucid.class_ "card mt-4"] <| do
+ Lucid.div_ [Lucid.class_ "card-title"] "Comments"
+ if null (Task.taskComments t)
+ then Lucid.p_ [Lucid.class_ "text-muted"] "No comments yet"
+ else
+ traverse_ renderComment (reverse (Task.taskComments t))
+ }
+
+renderComment :: Task.Comment -> Lucid.Html ()
+renderComment c =
+ Lucid.div_
+ [ Lucid.style_
+ "background: #0d1117; border: 1px solid #21262d; border-radius: 8px; padding: 12px; margin-bottom: 10px;"
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "text-small", Lucid.style_ "margin-bottom: 8px;"] <| do
+ Lucid.span_ [Lucid.style_ "font-weight: 600; margin-right: 8px;"] (Lucid.toHtml (authorLabel (Task.commentAuthor c)))
+ Lucid.span_ [Lucid.class_ "text-muted"] (Lucid.toHtml (formatTimestamp (Task.commentCreatedAt c)))
+ Lucid.div_ [Lucid.class_ "markdown-content"] (renderMarkdown (Task.commentText c))
+
+authorLabel :: Task.CommentAuthor -> Text
+authorLabel = \case
+ Task.Human -> "Human"
+ Task.System -> "System"
+ Task.Agent role -> "Agent(" <> tshow role <> ")"
metaRow :: Text -> Text -> Lucid.Html ()
-metaRow label value = Lucid.tr_ <| do
- Lucid.td_ [Lucid.style_ "width: 150px; color: #8b949e;"] (Lucid.toHtml label)
- Lucid.td_ (Lucid.toHtml value)
-
--- | Filter tasks by query and status
-filterTasks :: [Task.Task] -> Maybe Text -> Maybe Text -> [Task.Task]
-filterTasks tasks maybeQuery maybeStatus =
- let byStatus = case maybeStatus of
- Just "Open" -> filter (\t -> Task.taskStatus t == Task.Open) tasks
- Just "InProgress" -> filter (\t -> Task.taskStatus t == Task.InProgress) tasks
- Just "Done" -> filter (\t -> Task.taskStatus t == Task.Done) tasks
- Just "NeedsHelp" -> filter (\t -> Task.taskStatus t == Task.NeedsHelp) tasks
- _ -> tasks
- byQuery = case maybeQuery of
- Just q | not (Text.null q) ->
- filter (\t -> Text.isInfixOf (Text.toLower q) (Text.toLower <| Task.taskTitle t)) byStatus
- _ -> byStatus
- in take 100 byQuery
-
--- | Count tasks by status
-countByStatus :: [Task.Task] -> [(Text, Int)]
+metaRow label value =
+ Lucid.tr_ <| do
+ Lucid.td_ [Lucid.style_ "width: 170px; color: #8b949e;"] (Lucid.toHtml label)
+ Lucid.td_ (Lucid.toHtml value)
+
+-- | Filter tasks by query, status, priority, namespace, and type.
+applyFilters ::
+ [Task.Task] ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ Maybe Text ->
+ [Task.Task]
+applyFilters tasks maybeQuery maybeStatus maybePriority maybeNamespace maybeType =
+ filter matches tasks
+ where
+ queryNeedle = Text.toCaseFold </ nonEmpty maybeQuery
+ statusFilter = parseStatus =<< nonEmpty maybeStatus
+ priorityFilter = parsePriority =<< nonEmpty maybePriority
+ typeFilter = parseTaskType =<< nonEmpty maybeType
+ namespaceNeedle = Text.toCaseFold </ nonEmpty maybeNamespace
+
+ matches t =
+ matchesQuery t
+ && matchesStatus t
+ && matchesPriority t
+ && matchesNamespace t
+ && matchesType t
+
+ matchesQuery t = case queryNeedle of
+ Nothing -> True
+ Just needle ->
+ let haystack =
+ Text.toCaseFold
+ <| Text.intercalate
+ " "
+ [ Task.taskId t,
+ Task.taskTitle t,
+ Task.taskDescription t,
+ fromMaybe "" (Task.taskNamespace t),
+ fromMaybe "" (Task.taskParent t)
+ ]
+ in needle `Text.isInfixOf` haystack
+
+ matchesStatus t = maybe True (== Task.taskStatus t) statusFilter
+
+ matchesPriority t = maybe True (== Task.taskPriority t) priorityFilter
+
+ matchesNamespace t = case namespaceNeedle of
+ Nothing -> True
+ Just needle ->
+ maybe False (Text.isInfixOf needle <. Text.toCaseFold) (Task.taskNamespace t)
+
+ matchesType t = maybe True (== Task.taskType t) typeFilter
+
+-- | Sort task list according to requested sort key.
+sortTasks :: Maybe Text -> [Task.Task] -> [Task.Task]
+sortTasks maybeSort =
+ case fromMaybe "updated" maybeSort of
+ "created-asc" -> List.sortBy (comparing Task.taskCreatedAt)
+ "created-desc" -> List.sortBy (comparing (Down <. Task.taskCreatedAt))
+ "priority-high" -> List.sortBy (comparing Task.taskPriority <> comparing (Down <. Task.taskUpdatedAt))
+ "priority-low" -> List.sortBy (comparing (Down <. Task.taskPriority) <> comparing (Down <. Task.taskUpdatedAt))
+ "status" -> List.sortBy (comparing (statusOrder <. Task.taskStatus) <> comparing (Down <. Task.taskUpdatedAt))
+ _ -> List.sortBy (comparing (Down <. Task.taskUpdatedAt))
+
+countByStatus :: [Task.Task] -> [(Task.Status, Int)]
countByStatus tasks =
- [ ("Open", length <| filter (\t -> Task.taskStatus t == Task.Open) tasks)
- , ("InProgress", length <| filter (\t -> Task.taskStatus t == Task.InProgress) tasks)
- , ("Done", length <| filter (\t -> Task.taskStatus t == Task.Done) tasks)
- , ("NeedsHelp", length <| filter (\t -> Task.taskStatus t == Task.NeedsHelp) tasks)
+ [ (statusVal, length <| filter ((== statusVal) <. Task.taskStatus) tasks)
+ | statusVal <- allStatuses
+ ]
+
+allStatuses :: [Task.Status]
+allStatuses =
+ [ Task.Draft,
+ Task.Open,
+ Task.InProgress,
+ Task.Review,
+ Task.ReviewInProgress,
+ Task.Approved,
+ Task.Integrating,
+ Task.Verified,
+ Task.Done,
+ Task.NeedsHelp
]
+
+allPriorities :: [Task.Priority]
+allPriorities = [Task.P0, Task.P1, Task.P2, Task.P3, Task.P4]
+
+statusOrder :: Task.Status -> Int
+statusOrder = \case
+ Task.NeedsHelp -> 0
+ Task.Review -> 1
+ Task.ReviewInProgress -> 2
+ Task.InProgress -> 3
+ Task.Open -> 4
+ Task.Draft -> 5
+ Task.Approved -> 6
+ Task.Integrating -> 7
+ Task.Verified -> 8
+ Task.Done -> 9
+
+parseStatus :: Text -> Maybe Task.Status
+parseStatus = readMaybe <. Text.unpack
+
+parsePriority :: Text -> Maybe Task.Priority
+parsePriority = readMaybe <. Text.unpack
+
+parseTaskType :: Text -> Maybe Task.TaskType
+parseTaskType raw =
+ case Text.toCaseFold raw of
+ "epic" -> Just Task.Epic
+ "task" -> Just Task.WorkTask
+ "worktask" -> Just Task.WorkTask
+ other -> readMaybe (Text.unpack other)
+
+nonEmpty :: Maybe Text -> Maybe Text
+nonEmpty = \case
+ Nothing -> Nothing
+ Just t
+ | Text.null (Text.strip t) -> Nothing
+ | otherwise -> Just (Text.strip t)
+
+taskTypeLabel :: Task.TaskType -> Text
+taskTypeLabel Task.Epic = "Epic"
+taskTypeLabel Task.WorkTask = "Task"
+
+formatTimestamp :: UTCTime -> Text
+formatTimestamp ts = Text.pack (Time.formatTime Time.defaultTimeLocale "%Y-%m-%d %H:%M UTC" ts)
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index b5a3ef9a..d5ac4993 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -5,6 +5,7 @@
{-# LANGUAGE NoImplicitPrelude #-}
-- : dep http-api-data
+-- : dep sqlite-simple
module Omni.Task.Core where
import Alpha