summaryrefslogtreecommitdiff
path: root/Omni/Jr
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-01 18:44:47 -0500
committerBen Sima <ben@bensima.com>2025-12-01 18:44:47 -0500
commitdbbad7cff74411b39db6d619a2a1ad6512aad634 (patch)
treee205780cbe8ff065872f14f6095054084dc90c0e /Omni/Jr
parent838350a9afc27618abf9a78e721eb8902e99b6ab (diff)
Refactor Web.hs into smaller modules
Split 3231-line Web.hs into focused submodules: - Types.hs (346 lines): Data types, forms, API definition - Components.hs (1464 lines): Reusable UI components - Pages.hs (866 lines): Full page ToHtml instances - Partials.hs (247 lines): HTMX partial ToHtml instances - Handlers.hs (642 lines): Servant handler implementations - Web.hs (39 lines): Main module with run function Reduces main file by 99%, preventing agent token bloat. Task-Id: t-226 Amp-Thread-ID: https://ampcode.com/threads/T-355fae3a-03e9-4bdb-a1c7-6132576bf601 Co-authored-by: Amp <amp@ampcode.com>
Diffstat (limited to 'Omni/Jr')
-rw-r--r--Omni/Jr/Web.hs3226
-rw-r--r--Omni/Jr/Web/Components.hs1464
-rw-r--r--Omni/Jr/Web/Handlers.hs642
-rw-r--r--Omni/Jr/Web/Pages.hs866
-rw-r--r--Omni/Jr/Web/Partials.hs247
-rw-r--r--Omni/Jr/Web/Types.hs345
6 files changed, 3581 insertions, 3209 deletions
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 2a10cb5..761428e 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -1,3231 +1,39 @@
-{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
-{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE NoImplicitPrelude #-}
+-- | Jr Web UI - Main module that re-exports the API and provides the run function.
+--
+-- The web interface is split into submodules:
+-- - Types: Data types for pages, partials, and forms
+-- - Components: Reusable UI components and helpers
+-- - Pages: Full page ToHtml instances
+-- - Partials: HTMX partial ToHtml instances
+-- - Handlers: Servant handler implementations
+-- - Style: CSS styling
+--
-- : dep warp
-- : dep servant-server
-- : dep lucid
-- : dep servant-lucid
--- : dep http-api-data
--- : dep process
--- : dep clay
module Omni.Jr.Web
( run,
defaultPort,
+ -- Re-exports for external use
+ API,
+ server,
)
where
import Alpha
-import qualified Control.Concurrent as Concurrent
-import qualified Data.Aeson as Aeson
-import qualified Data.Aeson.KeyMap as KeyMap
-import qualified Data.ByteString.Lazy as LBS
-import qualified Data.List as List
-import qualified Data.Text as Text
-import qualified Data.Text.Lazy as LazyText
-import qualified Data.Text.Lazy.Encoding as LazyText
-import Data.Time (Day, NominalDiffTime, UTCTime (..), dayOfWeek, defaultTimeLocale, diffUTCTime, formatTime, getCurrentTime, toGregorian)
-import Data.Time.Calendar (DayOfWeek (..))
-import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
-import qualified Lucid
-import qualified Lucid.Base as Lucid
import qualified Network.Wai.Handler.Warp as Warp
-import Numeric (showFFloat)
-import qualified Omni.Fact as Fact
-import qualified Omni.Jr.Web.Style as Style
-import qualified Omni.Task.Core as TaskCore
-import Servant
-import qualified Servant.HTML.Lucid as Lucid
-import qualified Servant.Types.SourceT as Source
-import qualified System.Exit as Exit
-import qualified System.Process as Process
-import Web.FormUrlEncoded (FromForm (..), lookupUnique, parseUnique)
-
-type PostRedirect = Verb 'POST 303 '[Lucid.HTML] (Headers '[Header "Location" Text] NoContent)
+import Omni.Jr.Web.Handlers (api, server)
+import Omni.Jr.Web.Types (API)
+import Servant (serve)
defaultPort :: Warp.Port
defaultPort = 8080
-formatRelativeTime :: UTCTime -> UTCTime -> Text
-formatRelativeTime now timestamp =
- let delta = diffUTCTime now timestamp
- in relativeText delta
-
-relativeText :: NominalDiffTime -> Text
-relativeText delta
- | delta < 60 = "just now"
- | delta < 3600 = tshow (round (delta / 60) :: Int) <> " minutes ago"
- | delta < 7200 = "1 hour ago"
- | delta < 86400 = tshow (round (delta / 3600) :: Int) <> " hours ago"
- | delta < 172800 = "yesterday"
- | delta < 604800 = tshow (round (delta / 86400) :: Int) <> " days ago"
- | delta < 1209600 = "1 week ago"
- | delta < 2592000 = tshow (round (delta / 604800) :: Int) <> " weeks ago"
- | delta < 5184000 = "1 month ago"
- | delta < 31536000 = tshow (round (delta / 2592000) :: Int) <> " months ago"
- | otherwise = tshow (round (delta / 31536000) :: Int) <> " years ago"
-
-formatExactTimestamp :: UTCTime -> Text
-formatExactTimestamp = Text.pack <. formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S UTC"
-
-renderRelativeTimestamp :: (Monad m) => UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderRelativeTimestamp now timestamp =
- Lucid.span_
- [ Lucid.class_ "relative-time",
- Lucid.title_ (formatExactTimestamp timestamp)
- ]
- (Lucid.toHtml (formatRelativeTime now timestamp))
-
-metaSep :: (Monad m) => Lucid.HtmlT m ()
-metaSep = Lucid.span_ [Lucid.class_ "meta-sep"] "·"
-
-data TaskFilters = TaskFilters
- { filterStatus :: Maybe TaskCore.Status,
- filterPriority :: Maybe TaskCore.Priority,
- filterNamespace :: Maybe Text,
- filterType :: Maybe TaskCore.TaskType
- }
- deriving (Show, Eq)
-
-data TimeRange = Today | Week | Month | AllTime
- deriving (Show, Eq)
-
-data SortOrder
- = SortNewest
- | SortOldest
- | SortUpdated
- | SortPriorityHigh
- | SortPriorityLow
- deriving (Show, Eq)
-
-parseSortOrder :: Maybe Text -> SortOrder
-parseSortOrder (Just "oldest") = SortOldest
-parseSortOrder (Just "updated") = SortUpdated
-parseSortOrder (Just "priority-high") = SortPriorityHigh
-parseSortOrder (Just "priority-low") = SortPriorityLow
-parseSortOrder _ = SortNewest
-
-sortOrderToParam :: SortOrder -> Text
-sortOrderToParam SortNewest = "newest"
-sortOrderToParam SortOldest = "oldest"
-sortOrderToParam SortUpdated = "updated"
-sortOrderToParam SortPriorityHigh = "priority-high"
-sortOrderToParam SortPriorityLow = "priority-low"
-
-sortOrderLabel :: SortOrder -> Text
-sortOrderLabel SortNewest = "Newest First"
-sortOrderLabel SortOldest = "Oldest First"
-sortOrderLabel SortUpdated = "Recently Updated"
-sortOrderLabel SortPriorityHigh = "Priority (High to Low)"
-sortOrderLabel SortPriorityLow = "Priority (Low to High)"
-
-sortTasks :: SortOrder -> [TaskCore.Task] -> [TaskCore.Task]
-sortTasks SortNewest = List.sortBy (comparing (Down <. TaskCore.taskCreatedAt))
-sortTasks SortOldest = List.sortBy (comparing TaskCore.taskCreatedAt)
-sortTasks SortUpdated = List.sortBy (comparing (Down <. TaskCore.taskUpdatedAt))
-sortTasks SortPriorityHigh = List.sortBy (comparing TaskCore.taskPriority)
-sortTasks SortPriorityLow = List.sortBy (comparing (Down <. TaskCore.taskPriority))
-
-parseTimeRange :: Maybe Text -> TimeRange
-parseTimeRange (Just "today") = Today
-parseTimeRange (Just "week") = Week
-parseTimeRange (Just "month") = Month
-parseTimeRange _ = AllTime
-
-timeRangeToParam :: TimeRange -> Text
-timeRangeToParam Today = "today"
-timeRangeToParam Week = "week"
-timeRangeToParam Month = "month"
-timeRangeToParam AllTime = "all"
-
-getTimeRangeStart :: TimeRange -> UTCTime -> Maybe UTCTime
-getTimeRangeStart AllTime _ = Nothing
-getTimeRangeStart Today now = Just (startOfDay now)
-getTimeRangeStart Week now = Just (startOfWeek now)
-getTimeRangeStart Month now = Just (startOfMonth now)
-
-startOfDay :: UTCTime -> UTCTime
-startOfDay t = UTCTime (utctDay t) 0
-
-startOfWeek :: UTCTime -> UTCTime
-startOfWeek t =
- let day = utctDay t
- dow = dayOfWeek day
- daysBack = case dow of
- Monday -> 0
- Tuesday -> 1
- Wednesday -> 2
- Thursday -> 3
- Friday -> 4
- Saturday -> 5
- Sunday -> 6
- in UTCTime (addDays (negate daysBack) day) 0
-
-addDays :: Integer -> Day -> Day
-addDays n d =
- let (y, m, dayNum) = toGregorian d
- in fromGregorian y m (dayNum + fromInteger n)
-
-fromGregorian :: Integer -> Int -> Int -> Day
-fromGregorian y m d = toEnum (fromInteger (daysSinceEpoch y m d))
-
-daysSinceEpoch :: Integer -> Int -> Int -> Integer
-daysSinceEpoch y m d =
- let a = (14 - m) `div` 12
- y' = y + 4800 - toInteger a
- m' = m + 12 * a - 3
- jdn = d + (153 * m' + 2) `div` 5 + 365 * fromInteger y' + fromInteger y' `div` 4 - fromInteger y' `div` 100 + fromInteger y' `div` 400 - 32045
- in toInteger jdn - 2440588
-
-startOfMonth :: UTCTime -> UTCTime
-startOfMonth t =
- let day = utctDay t
- (y, m, _) = toGregorian day
- in UTCTime (fromGregorian y m 1) 0
-
-computeMetricsFromActivities :: [TaskCore.Task] -> [TaskCore.TaskActivity] -> TaskCore.AggregatedMetrics
-computeMetricsFromActivities tasks activities =
- let completedCount = length [t | t <- tasks, TaskCore.taskStatus t == TaskCore.Done]
- totalCost = sum [c | act <- activities, Just c <- [TaskCore.activityCostCents act]]
- totalTokens = sum [t | act <- activities, Just t <- [TaskCore.activityTokensUsed act]]
- totalDuration = sum [calcDuration act | act <- activities]
- in TaskCore.AggregatedMetrics
- { TaskCore.aggTotalCostCents = totalCost,
- TaskCore.aggTotalDurationSeconds = totalDuration,
- TaskCore.aggCompletedTasks = completedCount,
- TaskCore.aggTotalTokens = totalTokens
- }
- where
- calcDuration act = case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of
- (Just start, Just end) -> floor (diffUTCTime end start)
- _ -> 0
-
-type API =
- QueryParam "range" Text :> Get '[Lucid.HTML] HomePage
- :<|> "style.css" :> Get '[CSS] LazyText.Text
- :<|> "ready" :> QueryParam "sort" Text :> Get '[Lucid.HTML] ReadyQueuePage
- :<|> "blocked" :> QueryParam "sort" Text :> Get '[Lucid.HTML] BlockedPage
- :<|> "intervention" :> QueryParam "sort" Text :> Get '[Lucid.HTML] InterventionPage
- :<|> "stats" :> QueryParam "epic" Text :> Get '[Lucid.HTML] StatsPage
- :<|> "tasks"
- :> QueryParam "status" Text
- :> QueryParam "priority" Text
- :> QueryParam "namespace" Text
- :> QueryParam "type" Text
- :> QueryParam "sort" Text
- :> Get '[Lucid.HTML] TaskListPage
- :<|> "kb" :> Get '[Lucid.HTML] KBPage
- :<|> "kb" :> "create" :> ReqBody '[FormUrlEncoded] FactCreateForm :> PostRedirect
- :<|> "kb" :> Capture "id" Int :> Get '[Lucid.HTML] FactDetailPage
- :<|> "kb" :> Capture "id" Int :> "edit" :> ReqBody '[FormUrlEncoded] FactEditForm :> PostRedirect
- :<|> "kb" :> Capture "id" Int :> "delete" :> PostRedirect
- :<|> "epics" :> QueryParam "sort" Text :> Get '[Lucid.HTML] EpicsPage
- :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
- :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> Post '[Lucid.HTML] StatusBadgePartial
- :<|> "tasks" :> Capture "id" Text :> "priority" :> ReqBody '[FormUrlEncoded] PriorityForm :> Post '[Lucid.HTML] PriorityBadgePartial
- :<|> "tasks" :> Capture "id" Text :> "description" :> "view" :> Get '[Lucid.HTML] DescriptionViewPartial
- :<|> "tasks" :> Capture "id" Text :> "description" :> "edit" :> Get '[Lucid.HTML] DescriptionEditPartial
- :<|> "tasks" :> Capture "id" Text :> "description" :> ReqBody '[FormUrlEncoded] DescriptionForm :> Post '[Lucid.HTML] DescriptionViewPartial
- :<|> "tasks" :> Capture "id" Text :> "notes" :> ReqBody '[FormUrlEncoded] NotesForm :> PostRedirect
- :<|> "tasks" :> Capture "id" Text :> "comment" :> ReqBody '[FormUrlEncoded] CommentForm :> PostRedirect
- :<|> "tasks" :> Capture "id" Text :> "review" :> Get '[Lucid.HTML] TaskReviewPage
- :<|> "tasks" :> Capture "id" Text :> "diff" :> Capture "commit" Text :> Get '[Lucid.HTML] TaskDiffPage
- :<|> "tasks" :> Capture "id" Text :> "accept" :> PostRedirect
- :<|> "tasks" :> Capture "id" Text :> "reject" :> ReqBody '[FormUrlEncoded] RejectForm :> PostRedirect
- :<|> "tasks" :> Capture "id" Text :> "reset-retries" :> PostRedirect
- :<|> "partials" :> "recent-activity-new" :> QueryParam "since" Int :> Get '[Lucid.HTML] RecentActivityNewPartial
- :<|> "partials" :> "recent-activity-more" :> QueryParam "offset" Int :> Get '[Lucid.HTML] RecentActivityMorePartial
- :<|> "partials" :> "ready-count" :> Get '[Lucid.HTML] ReadyCountPartial
- :<|> "partials"
- :> "task-list"
- :> QueryParam "status" Text
- :> QueryParam "priority" Text
- :> QueryParam "namespace" Text
- :> QueryParam "type" Text
- :> QueryParam "sort" Text
- :> Get '[Lucid.HTML] TaskListPartial
- :<|> "partials" :> "task" :> Capture "id" Text :> "metrics" :> Get '[Lucid.HTML] TaskMetricsPartial
- :<|> "partials" :> "task" :> Capture "id" Text :> "events" :> QueryParam "since" Int :> Get '[Lucid.HTML] AgentEventsPartial
- :<|> "tasks" :> Capture "id" Text :> "events" :> "stream" :> StreamGet NoFraming SSE (SourceIO ByteString)
-
-data CSS
-
-instance Accept CSS where
- contentType _ = "text/css"
-
-instance MimeRender CSS LazyText.Text where
- mimeRender _ = LazyText.encodeUtf8
-
-data SSE
-
-instance Accept SSE where
- contentType _ = "text/event-stream"
-
-instance MimeRender SSE ByteString where
- mimeRender _ = LBS.fromStrict
-
-data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] Bool TaskCore.AggregatedMetrics TimeRange UTCTime
-
-data ReadyQueuePage = ReadyQueuePage [TaskCore.Task] SortOrder UTCTime
-
-data BlockedPage = BlockedPage [(TaskCore.Task, Int)] SortOrder UTCTime
-
-data InterventionPage = InterventionPage TaskCore.HumanActionItems SortOrder UTCTime
-
-data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters SortOrder UTCTime
-
-data TaskDetailPage
- = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) [GitCommit] (Maybe TaskCore.AggregatedMetrics) [TaskCore.StoredEvent] UTCTime
- | TaskDetailNotFound Text
-
-data GitCommit = GitCommit
- { commitHash :: Text,
- commitShortHash :: Text,
- commitSummary :: Text,
- commitAuthor :: Text,
- commitRelativeDate :: Text,
- commitFilesChanged :: Int
- }
- deriving (Show, Eq)
-
-data TaskReviewPage
- = ReviewPageFound TaskCore.Task ReviewInfo
- | ReviewPageNotFound Text
-
-data ReviewInfo
- = ReviewNoCommit
- | ReviewMergeConflict Text [Text]
- | ReviewReady Text Text
-
-data TaskDiffPage
- = DiffPageFound Text Text Text
- | DiffPageNotFound Text Text
-
-data StatsPage = StatsPage TaskCore.TaskStats (Maybe Text)
-
-newtype KBPage = KBPage [TaskCore.Fact]
-
-data FactDetailPage
- = FactDetailFound TaskCore.Fact UTCTime
- | FactDetailNotFound Int
-
-data FactEditForm = FactEditForm Text Text Text
-
-instance FromForm FactEditForm where
- fromForm form = do
- content <- parseUnique "content" form
- let files = fromRight "" (lookupUnique "files" form)
- let confidence = fromRight "0.8" (lookupUnique "confidence" form)
- Right (FactEditForm content files confidence)
-
-data FactCreateForm = FactCreateForm Text Text Text Text
-
-instance FromForm FactCreateForm where
- fromForm form = do
- project <- parseUnique "project" form
- content <- parseUnique "content" form
- let files = fromRight "" (lookupUnique "files" form)
- let confidence = fromRight "0.8" (lookupUnique "confidence" form)
- Right (FactCreateForm project content files confidence)
-
-data EpicsPage = EpicsPage [TaskCore.Task] [TaskCore.Task] SortOrder
-
-data RecentActivityNewPartial = RecentActivityNewPartial [TaskCore.Task] (Maybe Int)
-
-data RecentActivityMorePartial = RecentActivityMorePartial [TaskCore.Task] Int Bool
-
-newtype ReadyCountPartial = ReadyCountPartial Int
-
-data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text
-
-data PriorityBadgePartial = PriorityBadgePartial TaskCore.Priority Text
-
-newtype TaskListPartial = TaskListPartial [TaskCore.Task]
-
-data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) UTCTime
-
-data AgentEventsPartial = AgentEventsPartial [TaskCore.StoredEvent] Bool UTCTime
-
-data DescriptionViewPartial = DescriptionViewPartial Text Text Bool
-
-data DescriptionEditPartial = DescriptionEditPartial Text Text Bool
-
-newtype RejectForm = RejectForm (Maybe Text)
-
-instance FromForm RejectForm where
- fromForm form = Right (RejectForm (either (const Nothing) Just (lookupUnique "notes" form)))
-
-newtype StatusForm = StatusForm TaskCore.Status
-
-instance FromForm StatusForm where
- fromForm form = do
- statusText <- parseUnique "status" form
- case readMaybe (Text.unpack statusText) of
- Just s -> Right (StatusForm s)
- Nothing -> Left "Invalid status"
-
-newtype PriorityForm = PriorityForm TaskCore.Priority
-
-instance FromForm PriorityForm where
- fromForm form = do
- priorityText <- parseUnique "priority" form
- case readMaybe (Text.unpack priorityText) of
- Just p -> Right (PriorityForm p)
- Nothing -> Left "Invalid priority"
-
-newtype DescriptionForm = DescriptionForm Text
-
-instance FromForm DescriptionForm where
- fromForm form = do
- desc <- parseUnique "description" form
- Right (DescriptionForm desc)
-
-newtype NotesForm = NotesForm Text
-
-instance FromForm NotesForm where
- fromForm form = do
- notes <- parseUnique "notes" form
- Right (NotesForm notes)
-
-newtype CommentForm = CommentForm Text
-
-instance FromForm CommentForm where
- fromForm form = do
- commentText <- parseUnique "comment" form
- Right (CommentForm commentText)
-
-pageHead :: (Monad m) => Text -> Lucid.HtmlT m ()
-pageHead title =
- Lucid.head_ <| do
- Lucid.title_ (Lucid.toHtml title)
- Lucid.meta_ [Lucid.charset_ "utf-8"]
- Lucid.meta_
- [ Lucid.name_ "viewport",
- Lucid.content_ "width=device-width, initial-scale=1"
- ]
- Lucid.link_ [Lucid.rel_ "stylesheet", Lucid.href_ "/style.css"]
- Lucid.script_
- [ Lucid.src_ "https://unpkg.com/htmx.org@2.0.4",
- Lucid.integrity_ "sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+",
- Lucid.crossorigin_ "anonymous"
- ]
- ("" :: Text)
- Lucid.script_ [] statusDropdownJs
- Lucid.script_ [] priorityDropdownJs
- Lucid.script_ [] navbarDropdownJs
- Lucid.script_ [] liveToggleJs
-
-navbarDropdownJs :: Text
-navbarDropdownJs =
- Text.unlines
- [ "document.addEventListener('DOMContentLoaded', function() {",
- " document.querySelectorAll('.navbar-dropdown-btn').forEach(function(btn) {",
- " btn.addEventListener('click', function(e) {",
- " e.preventDefault();",
- " var dropdown = btn.closest('.navbar-dropdown');",
- " var isOpen = dropdown.classList.contains('open');",
- " document.querySelectorAll('.navbar-dropdown.open').forEach(function(d) {",
- " d.classList.remove('open');",
- " });",
- " if (!isOpen) {",
- " dropdown.classList.add('open');",
- " }",
- " });",
- " });",
- " document.addEventListener('click', function(e) {",
- " if (!e.target.closest('.navbar-dropdown')) {",
- " document.querySelectorAll('.navbar-dropdown.open').forEach(function(d) {",
- " d.classList.remove('open');",
- " });",
- " }",
- " });",
- "});"
- ]
-
-statusDropdownJs :: Text
-statusDropdownJs =
- Text.unlines
- [ "function toggleStatusDropdown(el) {",
- " var container = el.parentElement;",
- " var isOpen = container.classList.toggle('open');",
- " el.setAttribute('aria-expanded', isOpen);",
- " if (isOpen) {",
- " var firstItem = container.querySelector('[role=\"menuitem\"]');",
- " if (firstItem) firstItem.focus();",
- " }",
- "}",
- "",
- "function closeStatusDropdown(container) {",
- " container.classList.remove('open');",
- " var badge = container.querySelector('[role=\"button\"]');",
- " if (badge) {",
- " badge.setAttribute('aria-expanded', 'false');",
- " badge.focus();",
- " }",
- "}",
- "",
- "function handleStatusKeydown(event, el) {",
- " if (event.key === 'Enter' || event.key === ' ') {",
- " event.preventDefault();",
- " toggleStatusDropdown(el);",
- " } else if (event.key === 'Escape') {",
- " closeStatusDropdown(el.parentElement);",
- " } else if (event.key === 'ArrowDown') {",
- " event.preventDefault();",
- " var container = el.parentElement;",
- " if (!container.classList.contains('open')) {",
- " toggleStatusDropdown(el);",
- " } else {",
- " var firstItem = container.querySelector('[role=\"menuitem\"]');",
- " if (firstItem) firstItem.focus();",
- " }",
- " }",
- "}",
- "",
- "function handleMenuItemKeydown(event) {",
- " var container = event.target.closest('.status-badge-dropdown');",
- " var items = container.querySelectorAll('[role=\"menuitem\"]');",
- " var currentIndex = Array.from(items).indexOf(event.target);",
- " ",
- " if (event.key === 'ArrowDown') {",
- " event.preventDefault();",
- " var next = (currentIndex + 1) % items.length;",
- " items[next].focus();",
- " } else if (event.key === 'ArrowUp') {",
- " event.preventDefault();",
- " var prev = (currentIndex - 1 + items.length) % items.length;",
- " items[prev].focus();",
- " } else if (event.key === 'Escape') {",
- " event.preventDefault();",
- " closeStatusDropdown(container);",
- " } else if (event.key === 'Tab') {",
- " closeStatusDropdown(container);",
- " }",
- "}",
- "",
- "document.addEventListener('click', function(e) {",
- " var dropdowns = document.querySelectorAll('.status-badge-dropdown.open');",
- " dropdowns.forEach(function(d) {",
- " if (!d.contains(e.target)) {",
- " closeStatusDropdown(d);",
- " }",
- " });",
- "});"
- ]
-
-priorityDropdownJs :: Text
-priorityDropdownJs =
- Text.unlines
- [ "function togglePriorityDropdown(el) {",
- " var container = el.parentElement;",
- " var isOpen = container.classList.toggle('open');",
- " el.setAttribute('aria-expanded', isOpen);",
- " if (isOpen) {",
- " var firstItem = container.querySelector('[role=\"menuitem\"]');",
- " if (firstItem) firstItem.focus();",
- " }",
- "}",
- "",
- "function closePriorityDropdown(container) {",
- " container.classList.remove('open');",
- " var badge = container.querySelector('[role=\"button\"]');",
- " if (badge) {",
- " badge.setAttribute('aria-expanded', 'false');",
- " badge.focus();",
- " }",
- "}",
- "",
- "function handlePriorityKeydown(event, el) {",
- " if (event.key === 'Enter' || event.key === ' ') {",
- " event.preventDefault();",
- " togglePriorityDropdown(el);",
- " } else if (event.key === 'Escape') {",
- " closePriorityDropdown(el.parentElement);",
- " } else if (event.key === 'ArrowDown') {",
- " event.preventDefault();",
- " var container = el.parentElement;",
- " if (!container.classList.contains('open')) {",
- " togglePriorityDropdown(el);",
- " } else {",
- " var firstItem = container.querySelector('[role=\"menuitem\"]');",
- " if (firstItem) firstItem.focus();",
- " }",
- " }",
- "}",
- "",
- "function handlePriorityMenuItemKeydown(event) {",
- " var container = event.target.closest('.priority-badge-dropdown');",
- " var items = container.querySelectorAll('[role=\"menuitem\"]');",
- " var currentIndex = Array.from(items).indexOf(event.target);",
- " ",
- " if (event.key === 'ArrowDown') {",
- " event.preventDefault();",
- " var next = (currentIndex + 1) % items.length;",
- " items[next].focus();",
- " } else if (event.key === 'ArrowUp') {",
- " event.preventDefault();",
- " var prev = (currentIndex - 1 + items.length) % items.length;",
- " items[prev].focus();",
- " } else if (event.key === 'Escape') {",
- " event.preventDefault();",
- " closePriorityDropdown(container);",
- " } else if (event.key === 'Tab') {",
- " closePriorityDropdown(container);",
- " }",
- "}",
- "",
- "document.addEventListener('click', function(e) {",
- " var dropdowns = document.querySelectorAll('.priority-badge-dropdown.open');",
- " dropdowns.forEach(function(d) {",
- " if (!d.contains(e.target)) {",
- " closePriorityDropdown(d);",
- " }",
- " });",
- "});"
- ]
-
-pageBody :: (Monad m) => Lucid.HtmlT m () -> Lucid.HtmlT m ()
-pageBody content =
- Lucid.body_ [Lucid.makeAttribute "hx-boost" "true"] <| do
- navbar
- content
-
-data Breadcrumb = Breadcrumb
- { _crumbLabel :: Text,
- _crumbHref :: Maybe Text
- }
-
-type Breadcrumbs = [Breadcrumb]
-
-pageBodyWithCrumbs :: (Monad m) => Breadcrumbs -> Lucid.HtmlT m () -> Lucid.HtmlT m ()
-pageBodyWithCrumbs crumbs content =
- Lucid.body_ [Lucid.makeAttribute "hx-boost" "true"] <| do
- navbar
- unless (null crumbs) <| do
- Lucid.div_ [Lucid.class_ "breadcrumb-container"] <| do
- Lucid.div_ [Lucid.class_ "container"] <| renderBreadcrumbs crumbs
- content
-
-renderBreadcrumbs :: (Monad m) => Breadcrumbs -> Lucid.HtmlT m ()
-renderBreadcrumbs [] = pure ()
-renderBreadcrumbs crumbs =
- Lucid.nav_ [Lucid.class_ "breadcrumbs", Lucid.makeAttribute "aria-label" "Breadcrumb"] <| do
- Lucid.ol_ [Lucid.class_ "breadcrumb-list"] <| do
- traverse_ renderCrumb (zip [0 ..] crumbs)
- where
- renderCrumb :: (Monad m') => (Int, Breadcrumb) -> Lucid.HtmlT m' ()
- renderCrumb (idx, Breadcrumb label mHref) = do
- Lucid.li_ [Lucid.class_ "breadcrumb-item"] <| do
- when (idx > 0) <| Lucid.span_ [Lucid.class_ "breadcrumb-sep"] ">"
- case mHref of
- Just href -> Lucid.a_ [Lucid.href_ href] (Lucid.toHtml label)
- Nothing -> Lucid.span_ [Lucid.class_ "breadcrumb-current"] (Lucid.toHtml label)
-
-getAncestors :: [TaskCore.Task] -> TaskCore.Task -> [TaskCore.Task]
-getAncestors allTasks task =
- case TaskCore.taskParent task of
- Nothing -> [task]
- Just pid -> case TaskCore.findTask pid allTasks of
- Nothing -> [task]
- Just parent -> getAncestors allTasks parent ++ [task]
-
-taskBreadcrumbs :: [TaskCore.Task] -> TaskCore.Task -> Breadcrumbs
-taskBreadcrumbs allTasks task =
- let ancestors = getAncestors allTasks task
- taskCrumbs = [Breadcrumb (TaskCore.taskId t) (Just ("/tasks/" <> TaskCore.taskId t)) | t <- List.init ancestors]
- currentCrumb = Breadcrumb (TaskCore.taskId task) Nothing
- in [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks")]
- ++ taskCrumbs
- ++ [currentCrumb]
-
-navbar :: (Monad m) => Lucid.HtmlT m ()
-navbar =
- Lucid.nav_ [Lucid.class_ "navbar"] <| do
- Lucid.a_ [Lucid.href_ "/", Lucid.class_ "navbar-brand"] "Junior"
- Lucid.input_
- [ Lucid.type_ "checkbox",
- Lucid.id_ "navbar-toggle",
- Lucid.class_ "navbar-toggle-checkbox"
- ]
- Lucid.label_
- [ Lucid.for_ "navbar-toggle",
- Lucid.class_ "navbar-hamburger"
- ]
- <| do
- Lucid.span_ [Lucid.class_ "hamburger-line"] ""
- Lucid.span_ [Lucid.class_ "hamburger-line"] ""
- Lucid.span_ [Lucid.class_ "hamburger-line"] ""
- Lucid.div_ [Lucid.class_ "navbar-links"] <| do
- Lucid.a_ [Lucid.href_ "/", Lucid.class_ "navbar-link"] "Dashboard"
- Lucid.div_ [Lucid.class_ "navbar-dropdown"] <| do
- Lucid.button_ [Lucid.class_ "navbar-dropdown-btn"] "Tasks ▾"
- Lucid.div_ [Lucid.class_ "navbar-dropdown-content"] <| do
- Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "navbar-dropdown-item"] "Ready"
- Lucid.a_ [Lucid.href_ "/blocked", Lucid.class_ "navbar-dropdown-item"] "Blocked"
- Lucid.a_ [Lucid.href_ "/intervention", Lucid.class_ "navbar-dropdown-item"] "Human Action"
- Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "navbar-dropdown-item"] "All"
- Lucid.div_ [Lucid.class_ "navbar-dropdown"] <| do
- Lucid.button_ [Lucid.class_ "navbar-dropdown-btn"] "Plans ▾"
- Lucid.div_ [Lucid.class_ "navbar-dropdown-content"] <| do
- Lucid.a_ [Lucid.href_ "/epics", Lucid.class_ "navbar-dropdown-item"] "Epics"
- Lucid.a_ [Lucid.href_ "/kb", Lucid.class_ "navbar-dropdown-item"] "KB"
- Lucid.a_ [Lucid.href_ "/stats", Lucid.class_ "navbar-link"] "Stats"
-
-statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m ()
-statusBadge status =
- let (cls, label) = case status of
- TaskCore.Draft -> ("badge badge-draft", "Draft")
- TaskCore.Open -> ("badge badge-open", "Open")
- TaskCore.InProgress -> ("badge badge-inprogress", "In Progress")
- TaskCore.Review -> ("badge badge-review", "Review")
- TaskCore.Approved -> ("badge badge-approved", "Approved")
- TaskCore.Done -> ("badge badge-done", "Done")
- TaskCore.NeedsHelp -> ("badge badge-needshelp", "Needs Help")
- in Lucid.span_ [Lucid.class_ cls] label
-
-complexityBadge :: (Monad m) => Int -> Lucid.HtmlT m ()
-complexityBadge complexity =
- let cls = "badge badge-complexity badge-complexity-" <> tshow complexity
- label = "ℂ " <> tshow complexity
- in Lucid.span_ [Lucid.class_ cls, Lucid.title_ "Task Complexity (1-5)"] (Lucid.toHtml label)
-
-sortDropdown :: (Monad m) => Text -> SortOrder -> Lucid.HtmlT m ()
-sortDropdown basePath currentSort =
- Lucid.div_ [Lucid.class_ "sort-dropdown"] <| do
- Lucid.span_ [Lucid.class_ "sort-label"] "Sort:"
- Lucid.div_ [Lucid.class_ "sort-dropdown-wrapper navbar-dropdown"] <| do
- Lucid.button_ [Lucid.class_ "sort-dropdown-btn navbar-dropdown-btn"]
- <| Lucid.toHtml (sortOrderLabel currentSort <> " ▾")
- Lucid.div_ [Lucid.class_ "sort-dropdown-content navbar-dropdown-content"] <| do
- sortOption basePath SortNewest currentSort
- sortOption basePath SortOldest currentSort
- sortOption basePath SortUpdated currentSort
- sortOption basePath SortPriorityHigh currentSort
- sortOption basePath SortPriorityLow currentSort
-
-sortOption :: (Monad m) => Text -> SortOrder -> SortOrder -> Lucid.HtmlT m ()
-sortOption basePath option currentSort =
- let cls = "sort-dropdown-item navbar-dropdown-item" <> if option == currentSort then " active" else ""
- href = basePath <> "?sort=" <> sortOrderToParam option
- in Lucid.a_ [Lucid.href_ href, Lucid.class_ cls] (Lucid.toHtml (sortOrderLabel option))
-
-multiColorProgressBar :: (Monad m) => TaskCore.TaskStats -> Lucid.HtmlT m ()
-multiColorProgressBar stats =
- let total = TaskCore.totalTasks stats
- doneCount = TaskCore.doneTasks stats
- inProgressCount = TaskCore.inProgressTasks stats
- openCount = TaskCore.openTasks stats + TaskCore.reviewTasks stats + TaskCore.approvedTasks stats
- donePct = if total == 0 then 0 else (doneCount * 100) `div` total
- inProgressPct = if total == 0 then 0 else (inProgressCount * 100) `div` total
- openPct = if total == 0 then 0 else (openCount * 100) `div` total
- in Lucid.div_ [Lucid.class_ "multi-progress-container"] <| do
- Lucid.div_ [Lucid.class_ "multi-progress-bar"] <| do
- when (donePct > 0)
- <| Lucid.div_
- [ Lucid.class_ "multi-progress-segment progress-done",
- Lucid.style_ ("width: " <> tshow donePct <> "%"),
- Lucid.title_ (tshow doneCount <> " done")
- ]
- ""
- when (inProgressPct > 0)
- <| Lucid.div_
- [ Lucid.class_ "multi-progress-segment progress-inprogress",
- Lucid.style_ ("width: " <> tshow inProgressPct <> "%"),
- Lucid.title_ (tshow inProgressCount <> " in progress")
- ]
- ""
- when (openPct > 0)
- <| Lucid.div_
- [ Lucid.class_ "multi-progress-segment progress-open",
- Lucid.style_ ("width: " <> tshow openPct <> "%"),
- Lucid.title_ (tshow openCount <> " open")
- ]
- ""
- Lucid.div_ [Lucid.class_ "progress-legend"] <| do
- Lucid.span_ [Lucid.class_ "legend-item"] <| do
- Lucid.span_ [Lucid.class_ "legend-dot legend-done"] ""
- Lucid.toHtml ("Done " <> tshow doneCount)
- Lucid.span_ [Lucid.class_ "legend-item"] <| do
- Lucid.span_ [Lucid.class_ "legend-dot legend-inprogress"] ""
- Lucid.toHtml ("In Progress " <> tshow inProgressCount)
- Lucid.span_ [Lucid.class_ "legend-item"] <| do
- Lucid.span_ [Lucid.class_ "legend-dot legend-open"] ""
- Lucid.toHtml ("Open " <> tshow openCount)
-
-statusBadgeWithForm :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
-statusBadgeWithForm status tid =
- Lucid.div_
- [ Lucid.id_ "status-badge-container",
- Lucid.class_ "status-badge-dropdown"
- ]
- <| do
- clickableBadge status tid
- statusDropdownOptions status tid
-
-clickableBadge :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
-clickableBadge status _tid =
- let (cls, label) = case status of
- TaskCore.Draft -> ("badge badge-draft status-badge-clickable", "Draft" :: Text)
- TaskCore.Open -> ("badge badge-open status-badge-clickable", "Open")
- TaskCore.InProgress -> ("badge badge-inprogress status-badge-clickable", "In Progress")
- TaskCore.Review -> ("badge badge-review status-badge-clickable", "Review")
- TaskCore.Approved -> ("badge badge-approved status-badge-clickable", "Approved")
- TaskCore.Done -> ("badge badge-done status-badge-clickable", "Done")
- TaskCore.NeedsHelp -> ("badge badge-needshelp status-badge-clickable", "Needs Help")
- in Lucid.span_
- [ Lucid.class_ cls,
- Lucid.tabindex_ "0",
- Lucid.role_ "button",
- Lucid.makeAttribute "aria-haspopup" "true",
- Lucid.makeAttribute "aria-expanded" "false",
- Lucid.makeAttribute "onclick" "toggleStatusDropdown(this)",
- Lucid.makeAttribute "onkeydown" "handleStatusKeydown(event, this)"
- ]
- <| do
- Lucid.toHtml label
- Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾"
-
-statusDropdownOptions :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
-statusDropdownOptions currentStatus tid =
- Lucid.div_
- [ Lucid.class_ "status-dropdown-menu",
- Lucid.role_ "menu",
- Lucid.makeAttribute "aria-label" "Change task status"
- ]
- <| do
- statusOption TaskCore.Draft currentStatus tid
- statusOption TaskCore.Open currentStatus tid
- statusOption TaskCore.InProgress currentStatus tid
- statusOption TaskCore.Review currentStatus tid
- statusOption TaskCore.Approved currentStatus tid
- statusOption TaskCore.Done currentStatus tid
- statusOption TaskCore.NeedsHelp currentStatus tid
-
-statusOption :: (Monad m) => TaskCore.Status -> TaskCore.Status -> Text -> Lucid.HtmlT m ()
-statusOption opt currentStatus tid =
- let (cls, label) = case opt of
- TaskCore.Draft -> ("badge badge-draft", "Draft" :: Text)
- TaskCore.Open -> ("badge badge-open", "Open")
- TaskCore.InProgress -> ("badge badge-inprogress", "In Progress")
- TaskCore.Review -> ("badge badge-review", "Review")
- TaskCore.Approved -> ("badge badge-approved", "Approved")
- TaskCore.Done -> ("badge badge-done", "Done")
- TaskCore.NeedsHelp -> ("badge badge-needshelp", "Needs Help")
- isSelected = opt == currentStatus
- optClass = cls <> " status-dropdown-option" <> if isSelected then " selected" else ""
- in Lucid.form_
- [ Lucid.class_ "status-option-form",
- Lucid.role_ "none",
- Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/status"),
- Lucid.makeAttribute "hx-target" "#status-badge-container",
- Lucid.makeAttribute "hx-swap" "outerHTML"
- ]
- <| do
- Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "status", Lucid.value_ (tshow opt)]
- Lucid.button_
- [ Lucid.type_ "submit",
- Lucid.class_ optClass,
- Lucid.role_ "menuitem",
- Lucid.tabindex_ "-1",
- Lucid.makeAttribute "onkeydown" "handleMenuItemKeydown(event)"
- ]
- (Lucid.toHtml label)
-
-priorityBadgeWithForm :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m ()
-priorityBadgeWithForm priority tid =
- Lucid.div_
- [ Lucid.id_ "priority-badge-container",
- Lucid.class_ "priority-badge-dropdown"
- ]
- <| do
- clickablePriorityBadge priority tid
- priorityDropdownOptions priority tid
-
-clickablePriorityBadge :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m ()
-clickablePriorityBadge priority _tid =
- let (cls, label) = case priority of
- TaskCore.P0 -> ("badge badge-p0 priority-badge-clickable", "P0 Critical" :: Text)
- TaskCore.P1 -> ("badge badge-p1 priority-badge-clickable", "P1 High")
- TaskCore.P2 -> ("badge badge-p2 priority-badge-clickable", "P2 Normal")
- TaskCore.P3 -> ("badge badge-p3 priority-badge-clickable", "P3 Low")
- TaskCore.P4 -> ("badge badge-p4 priority-badge-clickable", "P4 Defer")
- in Lucid.span_
- [ Lucid.class_ cls,
- Lucid.tabindex_ "0",
- Lucid.role_ "button",
- Lucid.makeAttribute "aria-haspopup" "true",
- Lucid.makeAttribute "aria-expanded" "false",
- Lucid.makeAttribute "onclick" "togglePriorityDropdown(this)",
- Lucid.makeAttribute "onkeydown" "handlePriorityKeydown(event, this)"
- ]
- <| do
- Lucid.toHtml label
- Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾"
-
-priorityDropdownOptions :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m ()
-priorityDropdownOptions currentPriority tid =
- Lucid.div_
- [ Lucid.class_ "priority-dropdown-menu",
- Lucid.role_ "menu",
- Lucid.makeAttribute "aria-label" "Change task priority"
- ]
- <| do
- priorityOption TaskCore.P0 currentPriority tid
- priorityOption TaskCore.P1 currentPriority tid
- priorityOption TaskCore.P2 currentPriority tid
- priorityOption TaskCore.P3 currentPriority tid
- priorityOption TaskCore.P4 currentPriority tid
-
-priorityOption :: (Monad m) => TaskCore.Priority -> TaskCore.Priority -> Text -> Lucid.HtmlT m ()
-priorityOption opt currentPriority tid =
- let (cls, label) = case opt of
- TaskCore.P0 -> ("badge badge-p0", "P0 Critical" :: Text)
- TaskCore.P1 -> ("badge badge-p1", "P1 High")
- TaskCore.P2 -> ("badge badge-p2", "P2 Normal")
- TaskCore.P3 -> ("badge badge-p3", "P3 Low")
- TaskCore.P4 -> ("badge badge-p4", "P4 Defer")
- isSelected = opt == currentPriority
- optClass = cls <> " priority-dropdown-option" <> if isSelected then " selected" else ""
- in Lucid.form_
- [ Lucid.class_ "priority-option-form",
- Lucid.role_ "none",
- Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/priority"),
- Lucid.makeAttribute "hx-target" "#priority-badge-container",
- Lucid.makeAttribute "hx-swap" "outerHTML"
- ]
- <| do
- Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "priority", Lucid.value_ (tshow opt)]
- Lucid.button_
- [ Lucid.type_ "submit",
- Lucid.class_ optClass,
- Lucid.role_ "menuitem",
- Lucid.tabindex_ "-1",
- Lucid.makeAttribute "onkeydown" "handlePriorityMenuItemKeydown(event)"
- ]
- (Lucid.toHtml label)
-
-renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
-renderTaskCard t =
- Lucid.a_
- [ Lucid.class_ "task-card task-card-link",
- Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
- ]
- <| do
- Lucid.div_ [Lucid.class_ "task-header"] <| do
- Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (TaskCore.taskId t))
- statusBadge (TaskCore.taskStatus t)
- Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
- Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
-
-renderBlockedTaskCard :: (Monad m) => (TaskCore.Task, Int) -> Lucid.HtmlT m ()
-renderBlockedTaskCard (t, impact) =
- Lucid.a_
- [ Lucid.class_ "task-card task-card-link",
- Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
- ]
- <| do
- Lucid.div_ [Lucid.class_ "task-header"] <| do
- Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (TaskCore.taskId t))
- statusBadge (TaskCore.taskStatus t)
- Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
- when (impact > 0)
- <| Lucid.span_ [Lucid.class_ "blocking-impact"] (Lucid.toHtml ("Blocks " <> tshow impact))
- Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
-
-renderListGroupItem :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
-renderListGroupItem t =
- Lucid.a_
- [ Lucid.class_ "list-group-item",
- Lucid.href_ ("/tasks/" <> TaskCore.taskId t),
- Lucid.makeAttribute "hx-boost" "true",
- Lucid.makeAttribute "hx-target" "body",
- Lucid.makeAttribute "hx-swap" "innerHTML"
- ]
- <| do
- Lucid.div_ [Lucid.class_ "list-group-item-content"] <| do
- Lucid.span_ [Lucid.class_ "list-group-item-id"] (Lucid.toHtml (TaskCore.taskId t))
- Lucid.span_ [Lucid.class_ "list-group-item-title"] (Lucid.toHtml (TaskCore.taskTitle t))
- Lucid.div_ [Lucid.class_ "list-group-item-meta"] <| do
- statusBadge (TaskCore.taskStatus t)
- Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
-
-instance Lucid.ToHtml HomePage where
- toHtmlRaw = Lucid.toHtml
- toHtml (HomePage stats readyTasks recentTasks hasMoreRecent globalMetrics currentRange _now) =
- Lucid.doctypehtml_ <| do
- pageHead "Jr Dashboard"
- pageBody <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h2_ "Task Status"
- Lucid.div_ [Lucid.class_ "time-filter"] <| do
- timeFilterBtn "Today" Today currentRange
- timeFilterBtn "This Week" Week currentRange
- timeFilterBtn "This Month" Month currentRange
- timeFilterBtn "All Time" AllTime currentRange
- Lucid.div_ [Lucid.class_ "stats-grid"] <| do
- statCard "Open" (TaskCore.openTasks stats) "badge-open" "/tasks?status=Open"
- statCard "In Progress" (TaskCore.inProgressTasks stats) "badge-inprogress" "/tasks?status=InProgress"
- statCard "Review" (TaskCore.reviewTasks stats) "badge-review" "/tasks?status=Review"
- statCard "Approved" (TaskCore.approvedTasks stats) "badge-approved" "/tasks?status=Approved"
- statCard "Done" (TaskCore.doneTasks stats) "badge-done" "/tasks?status=Done"
- metricCard "Cost" (formatCost (TaskCore.aggTotalCostCents globalMetrics))
- metricCard "Duration" (formatDuration (TaskCore.aggTotalDurationSeconds globalMetrics))
-
- Lucid.h2_ <| do
- "Ready Queue "
- Lucid.span_
- [ Lucid.class_ "ready-count",
- Lucid.makeAttribute "hx-get" "/partials/ready-count",
- Lucid.makeAttribute "hx-trigger" "every 5s"
- ]
- <| do
- Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"]
- <| Lucid.toHtml ("(" <> tshow (length readyTasks) <> " tasks)")
- if null readyTasks
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks ready for work."
- else
- Lucid.div_ [Lucid.class_ "list-group"]
- <| traverse_ renderListGroupItem (take 5 readyTasks)
-
- Lucid.h2_ "Recent Activity"
- let newestTimestamp = maybe 0 taskToUnixTs (head recentTasks)
- Lucid.div_
- [ Lucid.class_ "recent-activity",
- Lucid.id_ "recent-activity",
- Lucid.makeAttribute "data-newest-ts" (tshow newestTimestamp),
- Lucid.makeAttribute "hx-get" "/partials/recent-activity-new",
- Lucid.makeAttribute "hx-trigger" "every 10s",
- Lucid.makeAttribute "hx-vals" "js:{since: document.getElementById('recent-activity')?.dataset?.newestTs || 0}",
- Lucid.makeAttribute "hx-target" "#activity-list",
- Lucid.makeAttribute "hx-swap" "afterbegin"
- ]
- <| do
- Lucid.div_ [Lucid.id_ "activity-list", Lucid.class_ "list-group"]
- <| traverse_ renderListGroupItem recentTasks
- when hasMoreRecent
- <| Lucid.button_
- [ Lucid.id_ "activity-load-more",
- Lucid.class_ "btn btn-secondary load-more-btn",
- Lucid.makeAttribute "hx-get" "/partials/recent-activity-more?offset=5",
- Lucid.makeAttribute "hx-target" "#activity-list",
- Lucid.makeAttribute "hx-swap" "beforeend"
- ]
- "Load More"
- where
- statCard :: (Monad m) => Text -> Int -> Text -> Text -> Lucid.HtmlT m ()
- statCard label count badgeClass href =
- Lucid.a_ [Lucid.href_ href, Lucid.class_ ("stat-card " <> badgeClass)] <| do
- Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml (tshow count))
- Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
-
- metricCard :: (Monad m) => Text -> Text -> Lucid.HtmlT m ()
- metricCard label value =
- Lucid.div_ [Lucid.class_ "stat-card badge-neutral"] <| do
- Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml value)
- Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
-
- formatCost :: Int -> Text
- formatCost cents =
- let dollars = fromIntegral cents / 100.0 :: Double
- in Text.pack ("$" <> showFFloat (Just 2) dollars "")
-
- formatDuration :: Int -> Text
- formatDuration totalSeconds
- | totalSeconds < 60 = tshow totalSeconds <> "s"
- | totalSeconds < 3600 =
- let mins = totalSeconds `div` 60
- in tshow mins <> "m"
- | otherwise =
- let hours = totalSeconds `div` 3600
- mins = (totalSeconds `mod` 3600) `div` 60
- in tshow hours <> "h " <> tshow mins <> "m"
-
- timeFilterBtn :: (Monad m) => Text -> TimeRange -> TimeRange -> Lucid.HtmlT m ()
- timeFilterBtn label range current =
- let activeClass = if range == current then " active" else ""
- href = "/?" <> "range=" <> timeRangeToParam range
- in Lucid.a_
- [ Lucid.href_ href,
- Lucid.class_ ("time-filter-btn" <> activeClass)
- ]
- (Lucid.toHtml label)
-
-instance Lucid.ToHtml ReadyQueuePage where
- toHtmlRaw = Lucid.toHtml
- toHtml (ReadyQueuePage tasks currentSort _now) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Ready Queue" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Ready Queue - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "page-header-row"] <| do
- Lucid.h1_ <| Lucid.toHtml ("Ready Queue (" <> tshow (length tasks) <> " tasks)")
- sortDropdown "/ready" currentSort
- if null tasks
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks are ready for work."
- else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks
-
-instance Lucid.ToHtml BlockedPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (BlockedPage tasksWithImpact currentSort _now) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Blocked" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Blocked Tasks - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "page-header-row"] <| do
- Lucid.h1_ <| Lucid.toHtml ("Blocked Tasks (" <> tshow (length tasksWithImpact) <> " tasks)")
- sortDropdown "/blocked" currentSort
- Lucid.p_ [Lucid.class_ "info-msg"] "Tasks with unmet blocking dependencies, sorted by blocking impact."
- if null tasksWithImpact
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No blocked tasks."
- else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderBlockedTaskCard tasksWithImpact
-
-instance Lucid.ToHtml InterventionPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (InterventionPage actionItems currentSort _now) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Needs Human Action" Nothing]
- failed = TaskCore.failedTasks actionItems
- epicsReady = TaskCore.epicsInReview actionItems
- needsHelp = TaskCore.tasksNeedingHelp actionItems
- totalCount = length failed + length epicsReady + length needsHelp
- in Lucid.doctypehtml_ <| do
- pageHead "Needs Human Action - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "page-header-row"] <| do
- Lucid.h1_ <| Lucid.toHtml ("Needs Human Action (" <> tshow totalCount <> " items)")
- sortDropdown "/intervention" currentSort
- if totalCount == 0
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No items need human action."
- else do
- unless (null failed) <| do
- Lucid.h2_ [Lucid.class_ "section-header"] <| Lucid.toHtml ("Failed Tasks (" <> tshow (length failed) <> ")")
- Lucid.p_ [Lucid.class_ "info-msg"] "Tasks that have failed 3+ times and need human help."
- Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard (sortTasks currentSort failed)
- unless (null epicsReady) <| do
- Lucid.h2_ [Lucid.class_ "section-header"] <| Lucid.toHtml ("Epics Ready for Review (" <> tshow (length epicsReady) <> ")")
- Lucid.p_ [Lucid.class_ "info-msg"] "Epics with all children completed. Verify before closing."
- Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderEpicReviewCard epicsReady
- unless (null needsHelp) <| do
- Lucid.h2_ [Lucid.class_ "section-header"] <| Lucid.toHtml ("Needs Help (" <> tshow (length needsHelp) <> ")")
- Lucid.p_ [Lucid.class_ "info-msg"] "Tasks where Jr needs human guidance or decisions."
- Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard (sortTasks currentSort needsHelp)
-
-renderEpicReviewCard :: (Monad m) => TaskCore.EpicForReview -> Lucid.HtmlT m ()
-renderEpicReviewCard epicReview = do
- let task = TaskCore.epicTask epicReview
- total = TaskCore.epicTotal epicReview
- completed = TaskCore.epicCompleted epicReview
- progressText = tshow completed <> "/" <> tshow total <> " subtasks done"
- Lucid.div_ [Lucid.class_ "task-card"] <| do
- Lucid.div_ [Lucid.class_ "task-card-header"] <| do
- Lucid.div_ [Lucid.class_ "task-title-row"] <| do
- Lucid.a_
- [Lucid.href_ ("/tasks/" <> TaskCore.taskId task), Lucid.class_ "task-link"]
- <| Lucid.toHtml (TaskCore.taskTitle task)
- Lucid.span_ [Lucid.class_ "badge badge-epic"] "Epic"
- Lucid.span_ [Lucid.class_ "task-id"] <| Lucid.toHtml (TaskCore.taskId task)
- Lucid.div_ [Lucid.class_ "task-card-body"] <| do
- Lucid.div_ [Lucid.class_ "progress-info"] <| do
- Lucid.span_ [Lucid.class_ "badge badge-success"] <| Lucid.toHtml progressText
- Lucid.div_ [Lucid.class_ "epic-actions"] <| do
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/status"),
- Lucid.class_ "inline-form"
- ]
- <| do
- Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "status", Lucid.value_ "done"]
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-success btn-sm"] "Approve & Close"
-
-instance Lucid.ToHtml KBPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (KBPage facts) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Knowledge Base" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Knowledge Base - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ "Knowledge Base"
- Lucid.p_ [Lucid.class_ "info-msg"] "Facts learned during task execution."
-
- Lucid.details_ [Lucid.class_ "create-fact-section"] <| do
- Lucid.summary_ [Lucid.class_ "btn btn-primary create-fact-toggle"] "Create New Fact"
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ "/kb/create",
- Lucid.class_ "fact-create-form"
- ]
- <| do
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "project"] "Project:"
- Lucid.input_
- [ Lucid.type_ "text",
- Lucid.name_ "project",
- Lucid.id_ "project",
- Lucid.class_ "form-input",
- Lucid.required_ "required",
- Lucid.placeholder_ "e.g., Omni/Jr"
- ]
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "content"] "Fact Content:"
- Lucid.textarea_
- [ Lucid.name_ "content",
- Lucid.id_ "content",
- Lucid.class_ "form-textarea",
- Lucid.rows_ "4",
- Lucid.required_ "required",
- Lucid.placeholder_ "Describe the fact or knowledge..."
- ]
- ""
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "files"] "Related Files (comma-separated):"
- Lucid.input_
- [ Lucid.type_ "text",
- Lucid.name_ "files",
- Lucid.id_ "files",
- Lucid.class_ "form-input",
- Lucid.placeholder_ "path/to/file1.hs, path/to/file2.hs"
- ]
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "confidence"] "Confidence (0.0 - 1.0):"
- Lucid.input_
- [ Lucid.type_ "number",
- Lucid.name_ "confidence",
- Lucid.id_ "confidence",
- Lucid.class_ "form-input",
- Lucid.step_ "0.1",
- Lucid.min_ "0",
- Lucid.max_ "1",
- Lucid.value_ "0.8"
- ]
- Lucid.div_ [Lucid.class_ "form-actions"] <| do
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Create Fact"
-
- if null facts
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No facts recorded yet."
- else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderFactCard facts
- where
- renderFactCard :: (Monad m) => TaskCore.Fact -> Lucid.HtmlT m ()
- renderFactCard f =
- let factUrl = "/kb/" <> maybe "-" tshow (TaskCore.factId f)
- in Lucid.a_
- [ Lucid.class_ "task-card task-card-link",
- Lucid.href_ factUrl
- ]
- <| do
- Lucid.div_ [Lucid.class_ "task-header"] <| do
- Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (maybe "-" tshow (TaskCore.factId f)))
- confidenceBadge (TaskCore.factConfidence f)
- Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (TaskCore.factProject f))
- Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (Text.take 80 (TaskCore.factContent f) <> if Text.length (TaskCore.factContent f) > 80 then "..." else ""))
- unless (null (TaskCore.factRelatedFiles f)) <| do
- Lucid.p_ [Lucid.class_ "kb-files"] <| do
- Lucid.span_ [Lucid.class_ "files-label"] "Files: "
- Lucid.toHtml (Text.intercalate ", " (take 3 (TaskCore.factRelatedFiles f)))
- when (length (TaskCore.factRelatedFiles f) > 3) <| do
- Lucid.toHtml (" +" <> tshow (length (TaskCore.factRelatedFiles f) - 3) <> " more")
-
- confidenceBadge :: (Monad m) => Double -> Lucid.HtmlT m ()
- confidenceBadge conf =
- let pct = floor (conf * 100) :: Int
- cls
- | conf >= 0.8 = "badge badge-done"
- | conf >= 0.5 = "badge badge-inprogress"
- | otherwise = "badge badge-open"
- in Lucid.span_ [Lucid.class_ cls] (Lucid.toHtml (tshow pct <> "%"))
-
-instance Lucid.ToHtml FactDetailPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (FactDetailNotFound fid) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Knowledge Base" (Just "/kb"), Breadcrumb ("Fact #" <> tshow fid) Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Fact Not Found - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ "Fact Not Found"
- Lucid.p_ [Lucid.class_ "error-msg"] (Lucid.toHtml ("Fact with ID " <> tshow fid <> " not found."))
- Lucid.a_ [Lucid.href_ "/kb", Lucid.class_ "btn btn-secondary"] "Back to Knowledge Base"
- toHtml (FactDetailFound fact now) =
- let fid' = maybe "-" tshow (TaskCore.factId fact)
- crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Knowledge Base" (Just "/kb"), Breadcrumb ("Fact #" <> fid') Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Fact Detail - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "task-detail-header"] <| do
- Lucid.h1_ <| do
- Lucid.span_ [Lucid.class_ "detail-id"] (Lucid.toHtml ("Fact #" <> maybe "-" tshow (TaskCore.factId fact)))
- Lucid.div_ [Lucid.class_ "task-meta-row"] <| do
- Lucid.span_ [Lucid.class_ "meta-label"] "Project:"
- Lucid.span_ [Lucid.class_ "meta-value"] (Lucid.toHtml (TaskCore.factProject fact))
- Lucid.span_ [Lucid.class_ "meta-label"] "Confidence:"
- confidenceBadgeDetail (TaskCore.factConfidence fact)
- Lucid.span_ [Lucid.class_ "meta-label"] "Created:"
- Lucid.span_ [Lucid.class_ "meta-value"] (renderRelativeTimestamp now (TaskCore.factCreatedAt fact))
-
- Lucid.div_ [Lucid.class_ "detail-section"] <| do
- Lucid.h2_ "Content"
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ ("/kb/" <> maybe "-" tshow (TaskCore.factId fact) <> "/edit"),
- Lucid.class_ "fact-edit-form"
- ]
- <| do
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "content"] "Fact Content:"
- Lucid.textarea_
- [ Lucid.name_ "content",
- Lucid.id_ "content",
- Lucid.class_ "form-textarea",
- Lucid.rows_ "6"
- ]
- (Lucid.toHtml (TaskCore.factContent fact))
-
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "files"] "Related Files (comma-separated):"
- Lucid.input_
- [ Lucid.type_ "text",
- Lucid.name_ "files",
- Lucid.id_ "files",
- Lucid.class_ "form-input",
- Lucid.value_ (Text.intercalate ", " (TaskCore.factRelatedFiles fact))
- ]
-
- Lucid.div_ [Lucid.class_ "form-group"] <| do
- Lucid.label_ [Lucid.for_ "confidence"] "Confidence (0.0 - 1.0):"
- Lucid.input_
- [ Lucid.type_ "number",
- Lucid.name_ "confidence",
- Lucid.id_ "confidence",
- Lucid.class_ "form-input",
- Lucid.step_ "0.1",
- Lucid.min_ "0",
- Lucid.max_ "1",
- Lucid.value_ (tshow (TaskCore.factConfidence fact))
- ]
-
- Lucid.div_ [Lucid.class_ "form-actions"] <| do
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Save Changes"
-
- case TaskCore.factSourceTask fact of
- Nothing -> pure ()
- Just tid -> do
- Lucid.div_ [Lucid.class_ "detail-section"] <| do
- Lucid.h2_ "Source Task"
- Lucid.a_ [Lucid.href_ ("/tasks/" <> tid), Lucid.class_ "task-link"] (Lucid.toHtml tid)
-
- Lucid.div_ [Lucid.class_ "detail-section danger-zone"] <| do
- Lucid.h2_ "Danger Zone"
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ ("/kb/" <> maybe "-" tshow (TaskCore.factId fact) <> "/delete"),
- Lucid.class_ "delete-form",
- Lucid.makeAttribute "onsubmit" "return confirm('Are you sure you want to delete this fact?');"
- ]
- <| do
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-danger"] "Delete Fact"
-
- Lucid.div_ [Lucid.class_ "back-link"] <| do
- Lucid.a_ [Lucid.href_ "/kb"] "← Back to Knowledge Base"
- where
- confidenceBadgeDetail :: (Monad m) => Double -> Lucid.HtmlT m ()
- confidenceBadgeDetail conf =
- let pct = floor (conf * 100) :: Int
- cls
- | conf >= 0.8 = "badge badge-done"
- | conf >= 0.5 = "badge badge-inprogress"
- | otherwise = "badge badge-open"
- in Lucid.span_ [Lucid.class_ cls] (Lucid.toHtml (tshow pct <> "%"))
-
-instance Lucid.ToHtml EpicsPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (EpicsPage epics allTasks currentSort) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Epics" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Epics - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "page-header-row"] <| do
- Lucid.h1_ <| Lucid.toHtml ("Epics (" <> tshow (length epics) <> ")")
- sortDropdown "/epics" currentSort
- Lucid.p_ [Lucid.class_ "info-msg"] "All epics (large, multi-task projects)."
- if null epics
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No epics found."
- else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ (renderEpicCardWithStats allTasks) epics
-
-epicProgressBar :: (Monad m) => Int -> Int -> Int -> Int -> Lucid.HtmlT m ()
-epicProgressBar doneCount inProgressCount openCount totalCount =
- let donePct = if totalCount == 0 then 0 else (doneCount * 100) `div` totalCount
- inProgressPct = if totalCount == 0 then 0 else (inProgressCount * 100) `div` totalCount
- openPct = if totalCount == 0 then 0 else (openCount * 100) `div` totalCount
- in Lucid.div_ [Lucid.class_ "multi-progress-container epic-progress"] <| do
- Lucid.div_ [Lucid.class_ "multi-progress-bar"] <| do
- when (donePct > 0)
- <| Lucid.div_
- [ Lucid.class_ "multi-progress-segment progress-done",
- Lucid.style_ ("width: " <> tshow donePct <> "%"),
- Lucid.title_ (tshow doneCount <> " done")
- ]
- ""
- when (inProgressPct > 0)
- <| Lucid.div_
- [ Lucid.class_ "multi-progress-segment progress-inprogress",
- Lucid.style_ ("width: " <> tshow inProgressPct <> "%"),
- Lucid.title_ (tshow inProgressCount <> " in progress")
- ]
- ""
- when (openPct > 0)
- <| Lucid.div_
- [ Lucid.class_ "multi-progress-segment progress-open",
- Lucid.style_ ("width: " <> tshow openPct <> "%"),
- Lucid.title_ (tshow openCount <> " open")
- ]
- ""
- Lucid.div_ [Lucid.class_ "progress-legend"] <| do
- Lucid.span_ [Lucid.class_ "legend-item"] <| do
- Lucid.span_ [Lucid.class_ "legend-dot legend-done"] ""
- Lucid.toHtml (tshow doneCount)
- Lucid.span_ [Lucid.class_ "legend-item"] <| do
- Lucid.span_ [Lucid.class_ "legend-dot legend-inprogress"] ""
- Lucid.toHtml (tshow inProgressCount)
- Lucid.span_ [Lucid.class_ "legend-item"] <| do
- Lucid.span_ [Lucid.class_ "legend-dot legend-open"] ""
- Lucid.toHtml (tshow openCount)
-
-renderEpicCardWithStats :: (Monad m) => [TaskCore.Task] -> TaskCore.Task -> Lucid.HtmlT m ()
-renderEpicCardWithStats allTasks t =
- let children = getDescendants allTasks (TaskCore.taskId t)
- openCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.Open]
- inProgressCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.InProgress]
- reviewCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.Review]
- doneCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.Done]
- totalCount = length children
- openAndReview = openCount + reviewCount
- in Lucid.a_
- [ Lucid.class_ "task-card task-card-link",
- Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
- ]
- <| do
- Lucid.div_ [Lucid.class_ "task-header"] <| do
- Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (TaskCore.taskId t))
- statusBadge (TaskCore.taskStatus t)
- Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
- Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
- when (totalCount > 0) <| epicProgressBar doneCount inProgressCount openAndReview totalCount
- unless (Text.null (TaskCore.taskDescription t))
- <| Lucid.p_ [Lucid.class_ "kb-preview"] (Lucid.toHtml (Text.take 200 (TaskCore.taskDescription t) <> "..."))
-
-getDescendants :: [TaskCore.Task] -> Text -> [TaskCore.Task]
-getDescendants allTasks parentId =
- let children = [c | c <- allTasks, maybe False (TaskCore.matchesId parentId) (TaskCore.taskParent c)]
- in children ++ concatMap (getDescendants allTasks <. TaskCore.taskId) children
-
-instance Lucid.ToHtml TaskListPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (TaskListPage tasks filters currentSort _now) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Tasks - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "page-header-row"] <| do
- Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")")
- sortDropdown "/tasks" currentSort
-
- Lucid.div_ [Lucid.class_ "filter-form"] <| do
- Lucid.form_
- [ Lucid.method_ "GET",
- Lucid.action_ "/tasks",
- Lucid.makeAttribute "hx-get" "/partials/task-list",
- Lucid.makeAttribute "hx-target" "#task-list",
- Lucid.makeAttribute "hx-push-url" "/tasks",
- Lucid.makeAttribute "hx-trigger" "submit, change from:select"
- ]
- <| do
- Lucid.div_ [Lucid.class_ "filter-row"] <| do
- Lucid.div_ [Lucid.class_ "filter-group"] <| do
- Lucid.label_ [Lucid.for_ "status"] "Status:"
- Lucid.select_ [Lucid.name_ "status", Lucid.id_ "status", Lucid.class_ "filter-select"] <| do
- Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterStatus filters)) "All"
- statusFilterOption TaskCore.Open (filterStatus filters)
- statusFilterOption TaskCore.InProgress (filterStatus filters)
- statusFilterOption TaskCore.Review (filterStatus filters)
- statusFilterOption TaskCore.Approved (filterStatus filters)
- statusFilterOption TaskCore.Done (filterStatus filters)
-
- Lucid.div_ [Lucid.class_ "filter-group"] <| do
- Lucid.label_ [Lucid.for_ "priority"] "Priority:"
- Lucid.select_ [Lucid.name_ "priority", Lucid.id_ "priority", Lucid.class_ "filter-select"] <| do
- Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterPriority filters)) "All"
- priorityFilterOption TaskCore.P0 (filterPriority filters)
- priorityFilterOption TaskCore.P1 (filterPriority filters)
- priorityFilterOption TaskCore.P2 (filterPriority filters)
- priorityFilterOption TaskCore.P3 (filterPriority filters)
- priorityFilterOption TaskCore.P4 (filterPriority filters)
-
- Lucid.div_ [Lucid.class_ "filter-group"] <| do
- Lucid.label_ [Lucid.for_ "namespace"] "Namespace:"
- Lucid.input_
- [ Lucid.type_ "text",
- Lucid.name_ "namespace",
- Lucid.id_ "namespace",
- Lucid.class_ "filter-input",
- Lucid.placeholder_ "e.g. Omni/Jr",
- Lucid.value_ (fromMaybe "" (filterNamespace filters))
- ]
-
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter"
- Lucid.a_
- [ Lucid.href_ "/tasks",
- Lucid.class_ "clear-btn",
- Lucid.makeAttribute "hx-get" "/partials/task-list",
- Lucid.makeAttribute "hx-target" "#task-list",
- Lucid.makeAttribute "hx-push-url" "/tasks"
- ]
- "Clear"
-
- Lucid.div_ [Lucid.id_ "task-list"] <| do
- if null tasks
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters."
- else Lucid.div_ [Lucid.class_ "list-group"] <| traverse_ renderListGroupItem tasks
- where
- maybeSelected :: (Eq a) => Maybe a -> Maybe a -> [Lucid.Attribute]
- maybeSelected opt current = [Lucid.selected_ "selected" | opt == current]
-
- statusFilterOption :: (Monad m) => TaskCore.Status -> Maybe TaskCore.Status -> Lucid.HtmlT m ()
- statusFilterOption s current =
- let attrs = [Lucid.value_ (tshow s)] <> [Lucid.selected_ "selected" | Just s == current]
- in Lucid.option_ attrs (Lucid.toHtml (tshow s))
-
- priorityFilterOption :: (Monad m) => TaskCore.Priority -> Maybe TaskCore.Priority -> Lucid.HtmlT m ()
- priorityFilterOption p current =
- let attrs = [Lucid.value_ (tshow p)] <> [Lucid.selected_ "selected" | Just p == current]
- in Lucid.option_ attrs (Lucid.toHtml (tshow p))
-
-instance Lucid.ToHtml TaskDetailPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (TaskDetailNotFound tid) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Task Not Found - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ "Task Not Found"
- Lucid.p_ <| do
- "The task "
- Lucid.code_ (Lucid.toHtml tid)
- " could not be found."
- toHtml (TaskDetailFound task allTasks _activities maybeRetry commits maybeAggMetrics agentEvents now) =
- let crumbs = taskBreadcrumbs allTasks task
- in Lucid.doctypehtml_ <| do
- pageHead (TaskCore.taskId task <> " - Jr")
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ <| Lucid.toHtml (TaskCore.taskTitle task)
-
- renderRetryContextBanner (TaskCore.taskId task) maybeRetry
-
- Lucid.div_ [Lucid.class_ "task-detail"] <| do
- Lucid.div_ [Lucid.class_ "task-meta"] <| do
- Lucid.div_ [Lucid.class_ "task-meta-primary"] <| do
- Lucid.code_ [Lucid.class_ "task-meta-id"] (Lucid.toHtml (TaskCore.taskId task))
- metaSep
- Lucid.span_ [Lucid.class_ "task-meta-type"] (Lucid.toHtml (tshow (TaskCore.taskType task)))
- metaSep
- statusBadgeWithForm (TaskCore.taskStatus task) (TaskCore.taskId task)
- metaSep
- priorityBadgeWithForm (TaskCore.taskPriority task) (TaskCore.taskId task)
- case TaskCore.taskComplexity task of
- Nothing -> pure ()
- Just c -> do
- metaSep
- complexityBadge c
- case TaskCore.taskNamespace task of
- Nothing -> pure ()
- Just ns -> do
- metaSep
- Lucid.span_ [Lucid.class_ "task-meta-ns"] (Lucid.toHtml ns)
-
- Lucid.div_ [Lucid.class_ "task-meta-secondary"] <| do
- case TaskCore.taskParent task of
- Nothing -> pure ()
- Just pid -> do
- Lucid.span_ [Lucid.class_ "task-meta-label"] "Parent:"
- Lucid.a_ [Lucid.href_ ("/tasks/" <> pid), Lucid.class_ "task-link"] (Lucid.toHtml pid)
- metaSep
- Lucid.span_ [Lucid.class_ "task-meta-label"] "Created"
- renderRelativeTimestamp now (TaskCore.taskCreatedAt task)
- metaSep
- Lucid.span_ [Lucid.class_ "task-meta-label"] "Updated"
- renderRelativeTimestamp now (TaskCore.taskUpdatedAt task)
-
- let deps = TaskCore.taskDependencies task
- unless (null deps) <| do
- Lucid.div_ [Lucid.class_ "detail-section"] <| do
- Lucid.h3_ "Dependencies"
- Lucid.ul_ [Lucid.class_ "dep-list"] <| do
- traverse_ renderDependency deps
-
- when (TaskCore.taskType task == TaskCore.Epic) <| do
- for_ maybeAggMetrics (renderAggregatedMetrics allTasks task)
-
- Lucid.div_ [Lucid.class_ "detail-section"] <| do
- Lucid.toHtml (DescriptionViewPartial (TaskCore.taskId task) (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
-
- let children = filter (maybe False (TaskCore.matchesId (TaskCore.taskId task)) <. TaskCore.taskParent) allTasks
- unless (null children) <| do
- Lucid.div_ [Lucid.class_ "detail-section"] <| do
- Lucid.h3_ "Child Tasks"
- Lucid.ul_ [Lucid.class_ "child-list"] <| do
- traverse_ renderChild children
-
- unless (null commits) <| do
- Lucid.div_ [Lucid.class_ "detail-section"] <| do
- Lucid.h3_ "Git Commits"
- Lucid.div_ [Lucid.class_ "commit-list"] <| do
- traverse_ (renderCommit (TaskCore.taskId task)) commits
-
- when (TaskCore.taskStatus task == TaskCore.Review) <| do
- Lucid.div_ [Lucid.class_ "review-link-section"] <| do
- Lucid.a_
- [ Lucid.href_ ("/tasks/" <> TaskCore.taskId task <> "/review"),
- Lucid.class_ "review-link-btn"
- ]
- "Review This Task"
-
- renderUnifiedTimeline (TaskCore.taskId task) (TaskCore.taskComments task) agentEvents (TaskCore.taskStatus task) now
- where
- renderDependency :: (Monad m) => TaskCore.Dependency -> Lucid.HtmlT m ()
- renderDependency dep =
- Lucid.li_ <| do
- Lucid.a_ [Lucid.href_ ("/tasks/" <> TaskCore.depId dep), Lucid.class_ "task-link"] (Lucid.toHtml (TaskCore.depId dep))
- Lucid.span_ [Lucid.class_ "dep-type"] <| Lucid.toHtml (" [" <> tshow (TaskCore.depType dep) <> "]")
-
- renderChild :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
- renderChild child =
- Lucid.li_ <| do
- Lucid.a_ [Lucid.href_ ("/tasks/" <> TaskCore.taskId child), Lucid.class_ "task-link"] (Lucid.toHtml (TaskCore.taskId child))
- Lucid.span_ [Lucid.class_ "child-title"] <| Lucid.toHtml (" - " <> TaskCore.taskTitle child)
- Lucid.span_ [Lucid.class_ "child-status"] <| Lucid.toHtml (" [" <> tshow (TaskCore.taskStatus child) <> "]")
-
- renderCommit :: (Monad m) => Text -> GitCommit -> Lucid.HtmlT m ()
- renderCommit tid c =
- Lucid.div_ [Lucid.class_ "commit-item"] <| do
- Lucid.div_ [Lucid.class_ "commit-header"] <| do
- Lucid.a_
- [ Lucid.href_ ("/tasks/" <> tid <> "/diff/" <> commitHash c),
- Lucid.class_ "commit-hash"
- ]
- (Lucid.toHtml (commitShortHash c))
- Lucid.span_ [Lucid.class_ "commit-summary"] (Lucid.toHtml (commitSummary c))
- Lucid.div_ [Lucid.class_ "commit-meta"] <| do
- Lucid.span_ [Lucid.class_ "commit-author"] (Lucid.toHtml (commitAuthor c))
- Lucid.span_ [Lucid.class_ "commit-date"] (Lucid.toHtml (commitRelativeDate c))
- Lucid.span_ [Lucid.class_ "commit-files"] (Lucid.toHtml (tshow (commitFilesChanged c) <> " files"))
-
-renderAggregatedMetrics :: (Monad m) => [TaskCore.Task] -> TaskCore.Task -> TaskCore.AggregatedMetrics -> Lucid.HtmlT m ()
-renderAggregatedMetrics allTasks task metrics =
- let descendants = getDescendants allTasks (TaskCore.taskId task)
- totalCount = length descendants
- costCents = TaskCore.aggTotalCostCents metrics
- durationSecs = TaskCore.aggTotalDurationSeconds metrics
- completedCount = TaskCore.aggCompletedTasks metrics
- tokensUsed = TaskCore.aggTotalTokens metrics
- in Lucid.div_ [Lucid.class_ "detail-section aggregated-metrics"] <| do
- Lucid.h3_ "Execution Summary"
- Lucid.div_ [Lucid.class_ "metrics-grid"] <| do
- Lucid.div_ [Lucid.class_ "metric-card"] <| do
- Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (tshow completedCount <> "/" <> tshow totalCount))
- Lucid.div_ [Lucid.class_ "metric-label"] "Tasks Completed"
- Lucid.div_ [Lucid.class_ "metric-card"] <| do
- Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCost costCents))
- Lucid.div_ [Lucid.class_ "metric-label"] "Total Cost"
- Lucid.div_ [Lucid.class_ "metric-card"] <| do
- Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDuration durationSecs))
- Lucid.div_ [Lucid.class_ "metric-label"] "Total Time"
- when (tokensUsed > 0) <| do
- Lucid.div_ [Lucid.class_ "metric-card"] <| do
- Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTokens tokensUsed))
- Lucid.div_ [Lucid.class_ "metric-label"] "Tokens Used"
- where
- formatCost :: Int -> Text
- formatCost cents =
- let dollars = fromIntegral cents / 100.0 :: Double
- in "$" <> Text.pack (showFFloat (Just 2) dollars "")
-
- formatDuration :: Int -> Text
- formatDuration secs
- | secs < 60 = tshow secs <> "s"
- | secs < 3600 =
- let mins = secs `div` 60
- remSecs = secs `mod` 60
- in tshow mins <> "m " <> tshow remSecs <> "s"
- | otherwise =
- let hrs = secs `div` 3600
- mins = (secs `mod` 3600) `div` 60
- in tshow hrs <> "h " <> tshow mins <> "m"
-
- formatTokens :: Int -> Text
- formatTokens t
- | t < 1000 = tshow t
- | t < 1000000 = Text.pack (showFFloat (Just 1) (fromIntegral t / 1000.0 :: Double) "") <> "K"
- | otherwise = Text.pack (showFFloat (Just 2) (fromIntegral t / 1000000.0 :: Double) "") <> "M"
-
-renderRetryContextBanner :: (Monad m) => Text -> Maybe TaskCore.RetryContext -> Lucid.HtmlT m ()
-renderRetryContextBanner _ Nothing = pure ()
-renderRetryContextBanner tid (Just ctx) =
- Lucid.div_ [Lucid.class_ bannerClass] <| do
- Lucid.div_ [Lucid.class_ "retry-banner-header"] <| do
- Lucid.span_ [Lucid.class_ "retry-icon"] retryIcon
- Lucid.span_ [Lucid.class_ "retry-attempt"] (Lucid.toHtml attemptText)
- when maxRetriesExceeded
- <| Lucid.span_ [Lucid.class_ "retry-warning-badge"] "Needs Human Intervention"
-
- Lucid.div_ [Lucid.class_ "retry-banner-details"] <| do
- Lucid.div_ [Lucid.class_ "retry-detail-row"] <| do
- Lucid.span_ [Lucid.class_ "retry-label"] "Failure Reason:"
- Lucid.span_ [Lucid.class_ "retry-value"] (Lucid.toHtml (summarizeReason (TaskCore.retryReason ctx)))
-
- let commit = TaskCore.retryOriginalCommit ctx
- unless (Text.null commit) <| do
- Lucid.div_ [Lucid.class_ "retry-detail-row"] <| do
- Lucid.span_ [Lucid.class_ "retry-label"] "Original Commit:"
- Lucid.code_ [Lucid.class_ "retry-commit"] (Lucid.toHtml (Text.take 8 commit))
-
- let conflicts = TaskCore.retryConflictFiles ctx
- unless (null conflicts) <| do
- Lucid.div_ [Lucid.class_ "retry-detail-row"] <| do
- Lucid.span_ [Lucid.class_ "retry-label"] "Conflict Files:"
- Lucid.ul_ [Lucid.class_ "retry-conflict-list"]
- <| traverse_ (Lucid.li_ <. Lucid.toHtml) conflicts
-
- when maxRetriesExceeded <| do
- Lucid.div_
- [Lucid.class_ "retry-warning-message"]
- "This task has exceeded the maximum number of retries. A human must review the failure and either fix the issue manually or reset the retry count."
-
- Lucid.p_ [Lucid.class_ "retry-hint"] "Use comments below to provide guidance for retry."
-
- Lucid.div_ [Lucid.class_ "retry-reset-section"] <| do
- Lucid.h4_ "Reset Retries"
- Lucid.p_ [Lucid.class_ "notes-help"] "Clear retry context and give task a fresh start:"
- Lucid.form_ [Lucid.method_ "POST", Lucid.action_ ("/tasks/" <> tid <> "/reset-retries")] <| do
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "reset-btn"] "Reset Retries"
- where
- attempt = TaskCore.retryAttempt ctx
- maxRetriesExceeded = attempt >= 3
- bannerClass = if maxRetriesExceeded then "retry-banner retry-banner-critical" else "retry-banner retry-banner-warning"
- retryIcon = if maxRetriesExceeded then "⚠" else "↻"
- attemptText = "Attempt " <> tshow attempt <> " of 3"
-
- summarizeReason :: Text -> Text
- summarizeReason reason
- | "rejected:" `Text.isPrefixOf` reason = "Rejected: " <> Text.strip (Text.drop 9 reason)
- | "Test failure:" `Text.isPrefixOf` reason = "Test failure (see details below)"
- | "MERGE CONFLICT" `Text.isPrefixOf` reason = "Merge conflict with concurrent changes"
- | otherwise = Text.take 100 reason <> if Text.length reason > 100 then "..." else ""
-
-instance Lucid.ToHtml TaskReviewPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (ReviewPageNotFound tid) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb "Review" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Task Not Found - Jr Review"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ "Task Not Found"
- Lucid.p_ <| do
- "The task "
- Lucid.code_ (Lucid.toHtml tid)
- " could not be found."
- toHtml (ReviewPageFound task reviewInfo) =
- let tid = TaskCore.taskId task
- crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb "Review" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead ("Review: " <> TaskCore.taskId task <> " - Jr")
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ "Review Task"
-
- Lucid.div_ [Lucid.class_ "task-summary"] <| do
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "ID:"
- Lucid.code_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskId task))
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "Title:"
- Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskTitle task))
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "Status:"
- Lucid.span_ [Lucid.class_ "detail-value"] <| statusBadge (TaskCore.taskStatus task)
-
- case reviewInfo of
- ReviewNoCommit ->
- Lucid.div_ [Lucid.class_ "no-commit-msg"] <| do
- Lucid.h3_ "No Commit Found"
- Lucid.p_ "No commit with this task ID was found in the git history."
- Lucid.p_ "The worker may not have completed yet, or the commit message doesn't include the task ID."
- ReviewMergeConflict commitSha conflictFiles ->
- Lucid.div_ [Lucid.class_ "conflict-warning"] <| do
- Lucid.h3_ "Merge Conflict Detected"
- Lucid.p_ <| do
- "Commit "
- Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha))
- " cannot be cleanly merged."
- Lucid.p_ "Conflicting files:"
- Lucid.ul_ <| traverse_ (Lucid.li_ <. Lucid.toHtml) conflictFiles
- ReviewReady commitSha diffText -> do
- Lucid.div_ [Lucid.class_ "diff-section"] <| do
- Lucid.h3_ <| do
- "Commit: "
- Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha))
- Lucid.pre_ [Lucid.class_ "diff-block"] (Lucid.toHtml diffText)
-
- Lucid.div_ [Lucid.class_ "review-actions"] <| do
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/accept"),
- Lucid.class_ "inline-form"
- ]
- <| do
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "accept-btn"] "Accept"
-
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/reject"),
- Lucid.class_ "reject-form"
- ]
- <| do
- Lucid.textarea_
- [ Lucid.name_ "notes",
- Lucid.class_ "reject-notes",
- Lucid.placeholder_ "Rejection notes (optional)"
- ]
- ""
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "reject-btn"] "Reject"
-
-instance Lucid.ToHtml TaskDiffPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (DiffPageNotFound tid commitHash') =
- let shortHash = Text.take 8 commitHash'
- crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb ("Diff " <> shortHash) Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Commit Not Found - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ "Commit Not Found"
- Lucid.p_ <| do
- "Could not find commit "
- Lucid.code_ (Lucid.toHtml commitHash')
- Lucid.a_ [Lucid.href_ ("/tasks/" <> tid), Lucid.class_ "back-link"] "← Back to task"
- toHtml (DiffPageFound tid commitHash' diffOutput) =
- let shortHash = Text.take 8 commitHash'
- crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb ("Diff " <> shortHash) Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead ("Diff " <> shortHash <> " - Jr")
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.div_ [Lucid.class_ "diff-header"] <| do
- Lucid.a_ [Lucid.href_ ("/tasks/" <> tid), Lucid.class_ "back-link"] "← Back to task"
- Lucid.h1_ <| do
- "Commit "
- Lucid.code_ (Lucid.toHtml shortHash)
- Lucid.pre_ [Lucid.class_ "diff-block"] (Lucid.toHtml diffOutput)
-
-instance Lucid.ToHtml StatsPage where
- toHtmlRaw = Lucid.toHtml
- toHtml (StatsPage stats maybeEpic) =
- let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Stats" Nothing]
- in Lucid.doctypehtml_ <| do
- pageHead "Task Statistics - Jr"
- pageBodyWithCrumbs crumbs <| do
- Lucid.div_ [Lucid.class_ "container"] <| do
- Lucid.h1_ <| case maybeEpic of
- Nothing -> "Task Statistics"
- Just epicId -> Lucid.toHtml ("Statistics for Epic: " <> epicId)
-
- Lucid.form_ [Lucid.method_ "GET", Lucid.action_ "/stats", Lucid.class_ "filter-form"] <| do
- Lucid.div_ [Lucid.class_ "filter-row"] <| do
- Lucid.div_ [Lucid.class_ "filter-group"] <| do
- Lucid.label_ [Lucid.for_ "epic"] "Epic:"
- Lucid.input_
- [ Lucid.type_ "text",
- Lucid.name_ "epic",
- Lucid.id_ "epic",
- Lucid.class_ "filter-input",
- Lucid.placeholder_ "Epic ID (optional)",
- Lucid.value_ (fromMaybe "" maybeEpic)
- ]
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter"
- Lucid.a_ [Lucid.href_ "/stats", Lucid.class_ "clear-btn"] "Clear"
-
- Lucid.h2_ "By Status"
- multiColorProgressBar stats
- Lucid.div_ [Lucid.class_ "stats-grid"] <| do
- statCard "Open" (TaskCore.openTasks stats) (TaskCore.totalTasks stats)
- statCard "In Progress" (TaskCore.inProgressTasks stats) (TaskCore.totalTasks stats)
- statCard "Review" (TaskCore.reviewTasks stats) (TaskCore.totalTasks stats)
- statCard "Approved" (TaskCore.approvedTasks stats) (TaskCore.totalTasks stats)
- statCard "Done" (TaskCore.doneTasks stats) (TaskCore.totalTasks stats)
-
- Lucid.h2_ "By Priority"
- Lucid.div_ [Lucid.class_ "stats-section"] <| do
- traverse_ (uncurry renderPriorityRow) (TaskCore.tasksByPriority stats)
-
- Lucid.h2_ "By Namespace"
- Lucid.div_ [Lucid.class_ "stats-section"] <| do
- if null (TaskCore.tasksByNamespace stats)
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No namespaces found."
- else traverse_ (uncurry (renderNamespaceRow (TaskCore.totalTasks stats))) (TaskCore.tasksByNamespace stats)
-
- Lucid.h2_ "Summary"
- Lucid.div_ [Lucid.class_ "summary-section"] <| do
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "Total Tasks:"
- Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.totalTasks stats)))
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "Epics:"
- Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.totalEpics stats)))
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "Ready:"
- Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.readyTasks stats)))
- Lucid.div_ [Lucid.class_ "detail-row"] <| do
- Lucid.span_ [Lucid.class_ "detail-label"] "Blocked:"
- Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.blockedTasks stats)))
- where
- statCard :: (Monad m) => Text -> Int -> Int -> Lucid.HtmlT m ()
- statCard label count total =
- let pct = if total == 0 then 0 else (count * 100) `div` total
- in Lucid.div_ [Lucid.class_ "stat-card"] <| do
- Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml (tshow count))
- Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
- Lucid.div_ [Lucid.class_ "progress-bar"] <| do
- Lucid.div_
- [ Lucid.class_ "progress-fill",
- Lucid.style_ ("width: " <> tshow pct <> "%")
- ]
- ""
-
- renderPriorityRow :: (Monad m) => TaskCore.Priority -> Int -> Lucid.HtmlT m ()
- renderPriorityRow priority count =
- let total = TaskCore.totalTasks stats
- pct = if total == 0 then 0 else (count * 100) `div` total
- in Lucid.div_ [Lucid.class_ "stats-row"] <| do
- Lucid.span_ [Lucid.class_ "stats-label"] (Lucid.toHtml (tshow priority))
- Lucid.div_ [Lucid.class_ "stats-bar-container"] <| do
- Lucid.div_ [Lucid.class_ "progress-bar"] <| do
- Lucid.div_
- [ Lucid.class_ "progress-fill",
- Lucid.style_ ("width: " <> tshow pct <> "%")
- ]
- ""
- Lucid.span_ [Lucid.class_ "stats-count"] (Lucid.toHtml (tshow count))
-
- renderNamespaceRow :: (Monad m) => Int -> Text -> Int -> Lucid.HtmlT m ()
- renderNamespaceRow total ns count =
- let pct = if total == 0 then 0 else (count * 100) `div` total
- in Lucid.div_ [Lucid.class_ "stats-row"] <| do
- Lucid.span_ [Lucid.class_ "stats-label"] (Lucid.toHtml ns)
- Lucid.div_ [Lucid.class_ "stats-bar-container"] <| do
- Lucid.div_ [Lucid.class_ "progress-bar"] <| do
- Lucid.div_
- [ Lucid.class_ "progress-fill",
- Lucid.style_ ("width: " <> tshow pct <> "%")
- ]
- ""
- Lucid.span_ [Lucid.class_ "stats-count"] (Lucid.toHtml (tshow count))
-
-instance Lucid.ToHtml RecentActivityNewPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (RecentActivityNewPartial tasks maybeNewestTs) = do
- traverse_ renderListGroupItem tasks
- case maybeNewestTs of
- Nothing -> pure ()
- Just ts ->
- Lucid.div_
- [ Lucid.id_ "recent-activity",
- Lucid.makeAttribute "data-newest-ts" (tshow ts),
- Lucid.makeAttribute "hx-swap-oob" "attributes:#recent-activity data-newest-ts"
- ]
- ""
-
-instance Lucid.ToHtml RecentActivityMorePartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (RecentActivityMorePartial tasks nextOffset hasMore) = do
- traverse_ renderListGroupItem tasks
- if hasMore
- then
- Lucid.button_
- [ Lucid.id_ "activity-load-more",
- Lucid.class_ "btn btn-secondary load-more-btn",
- Lucid.makeAttribute "hx-get" ("/partials/recent-activity-more?offset=" <> tshow nextOffset),
- Lucid.makeAttribute "hx-target" "#activity-list",
- Lucid.makeAttribute "hx-swap" "beforeend",
- Lucid.makeAttribute "hx-swap-oob" "true"
- ]
- "Load More"
- else Lucid.span_ [Lucid.id_ "activity-load-more", Lucid.makeAttribute "hx-swap-oob" "true"] ""
-
-instance Lucid.ToHtml ReadyCountPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (ReadyCountPartial count) =
- Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"]
- <| Lucid.toHtml ("(" <> tshow count <> " tasks)")
-
-instance Lucid.ToHtml StatusBadgePartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (StatusBadgePartial status tid) =
- statusBadgeWithForm status tid
-
-instance Lucid.ToHtml PriorityBadgePartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (PriorityBadgePartial priority tid) =
- priorityBadgeWithForm priority tid
-
-instance Lucid.ToHtml TaskListPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (TaskListPartial tasks) =
- if null tasks
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters."
- else Lucid.div_ [Lucid.class_ "list-group"] <| traverse_ renderListGroupItem tasks
-
-instance Lucid.ToHtml TaskMetricsPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (TaskMetricsPartial _tid activities maybeRetry now) =
- let runningActs = filter (\a -> TaskCore.activityStage a == TaskCore.Running) activities
- in if null runningActs
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No worker execution data available."
- else
- Lucid.div_ [Lucid.class_ "execution-details"] <| do
- let totalCost = sum [c | act <- runningActs, Just c <- [TaskCore.activityCostCents act]]
- totalDuration = sum [calcDurSecs act | act <- runningActs]
- attemptCount = length runningActs
-
- case maybeRetry of
- Nothing -> pure ()
- Just ctx ->
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Retry Attempt:"
- Lucid.span_ [Lucid.class_ "metric-value retry-count"] (Lucid.toHtml (tshow (TaskCore.retryAttempt ctx) <> "/3"))
-
- when (attemptCount > 1) <| do
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Total Attempts:"
- Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (tshow attemptCount))
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Total Duration:"
- Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDurSecs totalDuration))
- when (totalCost > 0)
- <| Lucid.div_ [Lucid.class_ "metric-row"]
- <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Total Cost:"
- Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCost totalCost))
- Lucid.hr_ [Lucid.class_ "attempts-divider"]
-
- traverse_ (renderAttempt attemptCount now) (zip [1 ..] (reverse runningActs))
- where
- calcDurSecs :: TaskCore.TaskActivity -> Int
- calcDurSecs act = case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of
- (Just start, Just end) -> floor (diffUTCTime end start)
- _ -> 0
-
- formatDurSecs :: Int -> Text
- formatDurSecs secs
- | secs < 60 = tshow secs <> "s"
- | secs < 3600 = tshow (secs `div` 60) <> "m " <> tshow (secs `mod` 60) <> "s"
- | otherwise = tshow (secs `div` 3600) <> "h " <> tshow ((secs `mod` 3600) `div` 60) <> "m"
-
- renderAttempt :: (Monad m) => Int -> UTCTime -> (Int, TaskCore.TaskActivity) -> Lucid.HtmlT m ()
- renderAttempt totalAttempts currentTime (attemptNum, act) = do
- when (totalAttempts > 1)
- <| Lucid.div_ [Lucid.class_ "attempt-header"] (Lucid.toHtml ("Attempt " <> tshow attemptNum :: Text))
- case TaskCore.activityThreadUrl act of
- Nothing -> pure ()
- Just url ->
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Session:"
- Lucid.a_ [Lucid.href_ url, Lucid.target_ "_blank", Lucid.class_ "amp-thread-btn"] "View in Amp ↗"
-
- case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of
- (Just start, Just end) ->
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Duration:"
- Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDuration start end))
- (Just start, Nothing) ->
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Started:"
- Lucid.span_ [Lucid.class_ "metric-value"] (renderRelativeTimestamp currentTime start)
- _ -> pure ()
-
- case TaskCore.activityCostCents act of
- Nothing -> pure ()
- Just cents ->
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Cost:"
- Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCost cents))
-
- Lucid.div_ [Lucid.class_ "metric-row"] <| do
- Lucid.span_ [Lucid.class_ "metric-label"] "Timestamp:"
- Lucid.span_ [Lucid.class_ "metric-value"] (renderRelativeTimestamp currentTime (TaskCore.activityTimestamp act))
-
- formatDuration :: UTCTime -> UTCTime -> Text
- formatDuration start end =
- let diffSecs = floor (diffUTCTime end start) :: Int
- mins = diffSecs `div` 60
- secs = diffSecs `mod` 60
- in if mins > 0
- then tshow mins <> "m " <> tshow secs <> "s"
- else tshow secs <> "s"
-
- formatCost :: Int -> Text
- formatCost cents =
- let dollars = fromIntegral cents / 100.0 :: Double
- in "$" <> Text.pack (showFFloat (Just 2) dollars "")
-
-instance Lucid.ToHtml DescriptionViewPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (DescriptionViewPartial tid desc isEpic) =
- Lucid.div_ [Lucid.id_ "description-block", Lucid.class_ "description-block"] <| do
- Lucid.div_ [Lucid.class_ "description-header"] <| do
- Lucid.h3_ (if isEpic then "Design" else "Description")
- Lucid.a_
- [ Lucid.href_ "#",
- Lucid.class_ "edit-link",
- Lucid.makeAttribute "hx-get" ("/tasks/" <> tid <> "/description/edit"),
- Lucid.makeAttribute "hx-target" "#description-block",
- Lucid.makeAttribute "hx-swap" "outerHTML"
- ]
- "Edit"
- if Text.null desc
- then Lucid.p_ [Lucid.class_ "empty-msg"] (if isEpic then "No design document yet." else "No description yet.")
- else Lucid.div_ [Lucid.class_ "markdown-content"] (renderMarkdown desc)
-
-instance Lucid.ToHtml DescriptionEditPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (DescriptionEditPartial tid desc isEpic) =
- Lucid.div_ [Lucid.id_ "description-block", Lucid.class_ "description-block editing"] <| do
- Lucid.div_ [Lucid.class_ "description-header"] <| do
- Lucid.h3_ (if isEpic then "Design" else "Description")
- Lucid.button_
- [ Lucid.type_ "button",
- Lucid.class_ "cancel-link",
- Lucid.makeAttribute "hx-get" ("/tasks/" <> tid <> "/description/view"),
- Lucid.makeAttribute "hx-target" "#description-block",
- Lucid.makeAttribute "hx-swap" "outerHTML",
- Lucid.makeAttribute "hx-confirm" "Discard changes?"
- ]
- "Cancel"
- Lucid.form_
- [ Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/description"),
- Lucid.makeAttribute "hx-target" "#description-block",
- Lucid.makeAttribute "hx-swap" "outerHTML"
- ]
- <| do
- Lucid.textarea_
- [ Lucid.name_ "description",
- Lucid.class_ "description-textarea",
- Lucid.rows_ (if isEpic then "15" else "10"),
- Lucid.placeholder_ (if isEpic then "Enter design in Markdown..." else "Enter description...")
- ]
- (Lucid.toHtml desc)
- Lucid.div_ [Lucid.class_ "form-actions"] <| do
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Save"
-
--- | Simple markdown renderer for epic descriptions
--- Supports: headers (#, ##, ###), lists (- or *), code blocks (```), inline code (`)
-renderMarkdown :: (Monad m) => Text -> Lucid.HtmlT m ()
-renderMarkdown input = renderBlocks (parseBlocks (Text.lines input))
-
-data MarkdownBlock
- = MdHeader Int Text
- | MdParagraph [Text]
- | MdCodeBlock [Text]
- | MdList [Text]
- deriving (Show, Eq)
-
-parseBlocks :: [Text] -> [MarkdownBlock]
-parseBlocks [] = []
-parseBlocks lns = case lns of
- (l : rest)
- | "```" `Text.isPrefixOf` l ->
- let (codeLines, afterCode) = List.span (not <. Text.isPrefixOf "```") rest
- remaining = List.drop 1 afterCode
- in MdCodeBlock codeLines : parseBlocks remaining
- | "### " `Text.isPrefixOf` l ->
- MdHeader 3 (Text.drop 4 l) : parseBlocks rest
- | "## " `Text.isPrefixOf` l ->
- MdHeader 2 (Text.drop 3 l) : parseBlocks rest
- | "# " `Text.isPrefixOf` l ->
- MdHeader 1 (Text.drop 2 l) : parseBlocks rest
- | isListItem l ->
- let (listLines, afterList) = List.span isListItem lns
- in MdList (map stripListPrefix listLines) : parseBlocks afterList
- | Text.null (Text.strip l) ->
- parseBlocks rest
- | otherwise ->
- let (paraLines, afterPara) = List.span isParagraphLine lns
- in MdParagraph paraLines : parseBlocks afterPara
- where
- isListItem t =
- let stripped = Text.stripStart t
- in "- " `Text.isPrefixOf` stripped || "* " `Text.isPrefixOf` stripped
- stripListPrefix t =
- let stripped = Text.stripStart t
- in Text.drop 2 stripped
- isParagraphLine t =
- not (Text.null (Text.strip t))
- && not ("```" `Text.isPrefixOf` t)
- && not ("#" `Text.isPrefixOf` t)
- && not (isListItem t)
-
-renderBlocks :: (Monad m) => [MarkdownBlock] -> Lucid.HtmlT m ()
-renderBlocks = traverse_ renderBlock
-
-renderBlock :: (Monad m) => MarkdownBlock -> Lucid.HtmlT m ()
-renderBlock block = case block of
- MdHeader 1 txt -> Lucid.h2_ [Lucid.class_ "md-h1"] (renderInline txt)
- MdHeader 2 txt -> Lucid.h3_ [Lucid.class_ "md-h2"] (renderInline txt)
- MdHeader 3 txt -> Lucid.h4_ [Lucid.class_ "md-h3"] (renderInline txt)
- MdHeader _ txt -> Lucid.h4_ (renderInline txt)
- MdParagraph lns -> Lucid.p_ [Lucid.class_ "md-para"] (renderInline (Text.unlines lns))
- MdCodeBlock lns -> Lucid.pre_ [Lucid.class_ "md-code"] (Lucid.code_ (Lucid.toHtml (Text.unlines lns)))
- MdList items -> Lucid.ul_ [Lucid.class_ "md-list"] (traverse_ renderListItem items)
-
-renderListItem :: (Monad m) => Text -> Lucid.HtmlT m ()
-renderListItem txt = Lucid.li_ (renderInline txt)
-
--- | Render inline markdown (backtick code, bold, italic)
-renderInline :: (Monad m) => Text -> Lucid.HtmlT m ()
-renderInline txt = renderInlineParts (parseInline txt)
-
-data InlinePart = PlainText Text | InlineCode Text | BoldText Text
- deriving (Show, Eq)
-
-parseInline :: Text -> [InlinePart]
-parseInline t
- | Text.null t = []
- | otherwise = case Text.breakOn "`" t of
- (before, rest)
- | Text.null rest -> parseBold before
- | otherwise ->
- let afterTick = Text.drop 1 rest
- in case Text.breakOn "`" afterTick of
- (code, rest2)
- | Text.null rest2 ->
- parseBold before ++ [PlainText ("`" <> afterTick)]
- | otherwise ->
- parseBold before ++ [InlineCode code] ++ parseInline (Text.drop 1 rest2)
-
-parseBold :: Text -> [InlinePart]
-parseBold t
- | Text.null t = []
- | otherwise = case Text.breakOn "**" t of
- (before, rest)
- | Text.null rest -> [PlainText before | not (Text.null before)]
- | otherwise ->
- let afterBold = Text.drop 2 rest
- in case Text.breakOn "**" afterBold of
- (boldText, rest2)
- | Text.null rest2 ->
- [PlainText before | not (Text.null before)] ++ [PlainText ("**" <> afterBold)]
- | otherwise ->
- [PlainText before | not (Text.null before)]
- ++ [BoldText boldText]
- ++ parseBold (Text.drop 2 rest2)
-
-renderInlineParts :: (Monad m) => [InlinePart] -> Lucid.HtmlT m ()
-renderInlineParts = traverse_ renderInlinePart
-
-renderInlinePart :: (Monad m) => InlinePart -> Lucid.HtmlT m ()
-renderInlinePart part = case part of
- PlainText txt -> Lucid.toHtml txt
- InlineCode txt -> Lucid.code_ [Lucid.class_ "md-inline-code"] (Lucid.toHtml txt)
- BoldText txt -> Lucid.strong_ (Lucid.toHtml txt)
-
--- | Comment form for adding new comments
-commentForm :: (Monad m) => Text -> Lucid.HtmlT m ()
-commentForm tid =
- Lucid.form_
- [ Lucid.method_ "POST",
- Lucid.action_ ("/tasks/" <> tid <> "/comment"),
- Lucid.class_ "comment-form"
- ]
- <| do
- Lucid.textarea_
- [ Lucid.name_ "comment",
- Lucid.placeholder_ "Add a comment...",
- Lucid.rows_ "3",
- Lucid.class_ "comment-textarea"
- ]
- ""
- Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Post Comment"
-
--- | Render the LIVE toggle button
-renderLiveToggle :: (Monad m) => Lucid.HtmlT m ()
-renderLiveToggle =
- Lucid.button_
- [ Lucid.class_ "timeline-live-toggle",
- Lucid.id_ "live-toggle",
- Lucid.makeAttribute "onclick" "toggleLiveUpdates()",
- Lucid.title_ "Click to pause/resume live updates"
- ]
- " LIVE"
-
--- | Render the autoscroll toggle button
-renderAutoscrollToggle :: (Monad m) => Lucid.HtmlT m ()
-renderAutoscrollToggle =
- Lucid.button_
- [ Lucid.class_ "timeline-autoscroll-toggle",
- Lucid.id_ "autoscroll-toggle",
- Lucid.makeAttribute "onclick" "toggleAutoscroll()",
- Lucid.title_ "Toggle automatic scrolling to newest events"
- ]
- " ⬇ Auto-scroll"
-
--- | JavaScript for toggling live updates and autoscroll
-liveToggleJs :: Text
-liveToggleJs =
- Text.unlines
- [ "var liveUpdatesEnabled = true;",
- "var autoscrollEnabled = true;",
- "",
- "function toggleLiveUpdates() {",
- " liveUpdatesEnabled = !liveUpdatesEnabled;",
- " var btn = document.getElementById('live-toggle');",
- " if (btn) {",
- " btn.classList.toggle('timeline-live-paused', !liveUpdatesEnabled);",
- " }",
- "}",
- "",
- "function toggleAutoscroll() {",
- " autoscrollEnabled = !autoscrollEnabled;",
- " var btn = document.getElementById('autoscroll-toggle');",
- " if (btn) {",
- " btn.classList.toggle('timeline-autoscroll-disabled', !autoscrollEnabled);",
- " }",
- "}",
- "",
- "document.body.addEventListener('htmx:beforeRequest', function(evt) {",
- " var timeline = document.getElementById('unified-timeline');",
- " if (timeline && timeline.contains(evt.target) && !liveUpdatesEnabled) {",
- " evt.preventDefault();",
- " }",
- "});",
- "",
- "document.body.addEventListener('htmx:afterSettle', function(evt) {",
- " if (autoscrollEnabled) {",
- " var log = document.querySelector('.timeline-events');",
- " if (log) {",
- " log.scrollTop = log.scrollHeight;",
- " }",
- " }",
- "});"
- ]
-
--- | Aggregate cost and token data from events (Cost event type)
-aggregateCostMetrics :: [TaskCore.StoredEvent] -> (Int, Int)
-aggregateCostMetrics events =
- let costEvents = filter (\e -> TaskCore.storedEventType e == "Cost") events
- aggregateOne (totalCents, totalTokens) event =
- case Aeson.decode (LBS.fromStrict (str (TaskCore.storedEventContent event))) of
- Just (Aeson.Object obj) ->
- let cents = case KeyMap.lookup "cents" obj of
- Just (Aeson.Number n) -> floor n
- _ -> 0
- tokens = case KeyMap.lookup "tokens" obj of
- Just (Aeson.Number n) -> floor n
- _ -> 0
- in (totalCents + cents, totalTokens + tokens)
- _ -> (totalCents, totalTokens)
- in foldl' aggregateOne (0, 0) costEvents
-
--- | Format cost in dollars
-formatCostHeader :: Int -> Text
-formatCostHeader cents =
- let dollars = fromIntegral cents / 100.0 :: Double
- in "$" <> Text.pack (showFFloat (Just 2) dollars "")
-
--- | Format tokens with K/M suffixes
-formatTokensHeader :: Int -> Text
-formatTokensHeader t
- | t < 1000 = tshow t
- | t < 1000000 = Text.pack (showFFloat (Just 1) (fromIntegral t / 1000.0 :: Double) "") <> "K"
- | otherwise = Text.pack (showFFloat (Just 2) (fromIntegral t / 1000000.0 :: Double) "") <> "M"
-
--- | Unified timeline view combining comments, status changes, and agent events
-renderUnifiedTimeline :: (Monad m) => Text -> [TaskCore.Comment] -> [TaskCore.StoredEvent] -> TaskCore.Status -> UTCTime -> Lucid.HtmlT m ()
-renderUnifiedTimeline tid legacyComments events status now = do
- let isInProgress = status == TaskCore.InProgress
- pollAttrs =
- if isInProgress
- then
- [ Lucid.makeAttribute "hx-get" ("/partials/task/" <> tid <> "/events"),
- Lucid.makeAttribute "hx-trigger" "every 3s",
- Lucid.makeAttribute "hx-swap" "innerHTML",
- Lucid.makeAttribute "hx-on::before-request" "var log = this.querySelector('.timeline-events'); if(log) this.dataset.scroll = log.scrollTop",
- Lucid.makeAttribute "hx-on::after-swap" "var log = this.querySelector('.timeline-events'); if(log && this.dataset.scroll) log.scrollTop = this.dataset.scroll"
- ]
- else []
- -- Count non-Cost events for the display
- nonCostEvents = filter (\e -> TaskCore.storedEventType e /= "Cost") events
- eventCount = length nonCostEvents + length legacyComments
- (totalCents, totalTokens) = aggregateCostMetrics events
- Lucid.div_ ([Lucid.class_ "unified-timeline-section", Lucid.id_ "unified-timeline"] <> pollAttrs) <| do
- Lucid.h3_ <| do
- Lucid.toHtml ("Timeline (" <> tshow eventCount <> ")")
- when (totalCents > 0 || totalTokens > 0) <| do
- Lucid.span_ [Lucid.class_ "timeline-cost-summary"] <| do
- metaSep
- when (totalCents > 0) <| Lucid.toHtml (formatCostHeader totalCents)
- when (totalCents > 0 && totalTokens > 0) <| metaSep
- when (totalTokens > 0) <| Lucid.toHtml (formatTokensHeader totalTokens <> " tokens")
- when isInProgress <| do
- renderLiveToggle
- renderAutoscrollToggle
-
- if null nonCostEvents && null legacyComments
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No activity yet."
- else do
- Lucid.div_ [Lucid.class_ "timeline-events"] <| do
- traverse_ (renderTimelineEvent now) nonCostEvents
- when isInProgress <| timelineScrollScript
-
- commentForm tid
-
--- | Render a single timeline event with icon, actor label, and timestamp
-renderTimelineEvent :: (Monad m) => UTCTime -> TaskCore.StoredEvent -> Lucid.HtmlT m ()
-renderTimelineEvent now event =
- let eventType = TaskCore.storedEventType event
- content = TaskCore.storedEventContent event
- timestamp = TaskCore.storedEventTimestamp event
- actor = TaskCore.storedEventActor event
- eventId = TaskCore.storedEventId event
- (icon, label) = eventTypeIconAndLabel eventType
- in Lucid.div_
- [ Lucid.class_ ("timeline-event timeline-event-" <> eventType),
- Lucid.makeAttribute "data-event-id" (tshow eventId)
- ]
- <| do
- case eventType of
- "comment" -> renderCommentTimelineEvent content actor timestamp now
- "status_change" -> renderStatusChangeEvent content actor timestamp now
- "claim" -> renderActivityEvent icon label content actor timestamp now
- "running" -> renderActivityEvent icon label content actor timestamp now
- "reviewing" -> renderActivityEvent icon label content actor timestamp now
- "retrying" -> renderActivityEvent icon label content actor timestamp now
- "complete" -> renderActivityEvent icon label content actor timestamp now
- "error" -> renderErrorTimelineEvent content actor timestamp now
- "Assistant" -> renderAssistantTimelineEvent content actor timestamp now
- "ToolCall" -> renderToolCallTimelineEvent content actor timestamp now
- "ToolResult" -> renderToolResultTimelineEvent content actor timestamp now
- "Cost" -> pure () -- Cost events are hidden; cost data shown in timeline header
- "Checkpoint" -> renderCheckpointEvent content actor timestamp now
- "Guardrail" -> renderGuardrailEvent content actor timestamp now
- _ -> renderGenericEvent eventType content actor timestamp now
-
--- | Get icon and label for event type
-eventTypeIconAndLabel :: Text -> (Text, Text)
-eventTypeIconAndLabel "comment" = ("💬", "Comment")
-eventTypeIconAndLabel "status_change" = ("🔄", "Status")
-eventTypeIconAndLabel "claim" = ("🤖", "Claimed")
-eventTypeIconAndLabel "running" = ("▶️", "Running")
-eventTypeIconAndLabel "reviewing" = ("👀", "Reviewing")
-eventTypeIconAndLabel "retrying" = ("🔁", "Retrying")
-eventTypeIconAndLabel "complete" = ("✅", "Complete")
-eventTypeIconAndLabel "error" = ("❌", "Error")
-eventTypeIconAndLabel "Assistant" = ("💭", "Thought")
-eventTypeIconAndLabel "ToolCall" = ("🔧", "Tool")
-eventTypeIconAndLabel "ToolResult" = ("📄", "Result")
-eventTypeIconAndLabel "Cost" = ("💰", "Cost")
-eventTypeIconAndLabel "Checkpoint" = ("📍", "Checkpoint")
-eventTypeIconAndLabel "Guardrail" = ("⚠️", "Guardrail")
-eventTypeIconAndLabel t = ("📝", t)
-
--- | Render actor label
-renderActorLabel :: (Monad m) => TaskCore.CommentAuthor -> Lucid.HtmlT m ()
-renderActorLabel actor =
- let (cls, label) :: (Text, Text) = case actor of
- TaskCore.Human -> ("actor-human", "human")
- TaskCore.Junior -> ("actor-junior", "junior")
- TaskCore.System -> ("actor-system", "system")
- in Lucid.span_ [Lucid.class_ ("actor-label " <> cls)] (Lucid.toHtml ("[" <> label <> "]"))
-
--- | Render comment event
-renderCommentTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderCommentTimelineEvent content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-comment"] <| do
- Lucid.div_ [Lucid.class_ "event-header"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "💬"
- renderActorLabel actor
- renderRelativeTimestamp now timestamp
- Lucid.div_ [Lucid.class_ "event-content comment-bubble markdown-content"] <| do
- renderMarkdown content
-
--- | Render status change event
-renderStatusChangeEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderStatusChangeEvent content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-status-change"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "🔄"
- renderActorLabel actor
- Lucid.span_ [Lucid.class_ "status-change-text"] (Lucid.toHtml (parseStatusChange content))
- renderRelativeTimestamp now timestamp
-
--- | Parse status change JSON
-parseStatusChange :: Text -> Text
-parseStatusChange content =
- case Aeson.decode (LBS.fromStrict (str content)) of
- Just (Aeson.Object obj) ->
- let fromStatus = case KeyMap.lookup "from" obj of
- Just (Aeson.String s) -> s
- _ -> "?"
- toStatus = case KeyMap.lookup "to" obj of
- Just (Aeson.String s) -> s
- _ -> "?"
- in fromStatus <> " → " <> toStatus
- _ -> content
-
--- | Render activity event (claim, running, etc.)
-renderActivityEvent :: (Monad m) => Text -> Text -> Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderActivityEvent icon label content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-activity"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] (Lucid.toHtml icon)
- Lucid.span_ [Lucid.class_ "event-label"] (Lucid.toHtml label)
- renderActorLabel actor
- unless (Text.null content) <| Lucid.span_ [Lucid.class_ "activity-detail"] (Lucid.toHtml content)
- renderRelativeTimestamp now timestamp
-
--- | Render error event
-renderErrorTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderErrorTimelineEvent content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-error"] <| do
- Lucid.div_ [Lucid.class_ "event-header"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "❌"
- Lucid.span_ [Lucid.class_ "event-label"] "Error"
- renderActorLabel actor
- renderRelativeTimestamp now timestamp
- Lucid.div_ [Lucid.class_ "event-content error-message"] (Lucid.toHtml content)
-
--- | Render assistant thought event
-renderAssistantTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderAssistantTimelineEvent content _actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-thought"] <| do
- Lucid.div_ [Lucid.class_ "event-header"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "💭"
- Lucid.span_ [Lucid.class_ "event-label"] "Thought"
- renderActorLabel TaskCore.Junior
- renderRelativeTimestamp now timestamp
- Lucid.div_ [Lucid.class_ "event-content thought-bubble markdown-content"] <| do
- let truncated = Text.take 2000 content
- isTruncated = Text.length content > 2000
- renderMarkdown truncated
- when isTruncated <| Lucid.span_ [Lucid.class_ "event-truncated"] "..."
-
--- | Render tool call event
-renderToolCallTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderToolCallTimelineEvent content _actor timestamp now =
- let (toolName, args) = parseToolCallContent content
- summary = formatToolCallSummary toolName args
- in Lucid.details_ [Lucid.class_ "timeline-tool-call"] <| do
- Lucid.summary_ <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "🔧"
- Lucid.span_ [Lucid.class_ "tool-name"] (Lucid.toHtml toolName)
- Lucid.span_ [Lucid.class_ "tool-summary"] (Lucid.toHtml summary)
- renderRelativeTimestamp now timestamp
- Lucid.div_ [Lucid.class_ "event-content tool-args"] <| do
- renderCollapsibleOutput args
-
--- | Render tool result event (collapsed by default)
-renderToolResultTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderToolResultTimelineEvent content _actor timestamp now =
- let lineCount = length (Text.lines content)
- in Lucid.details_ [Lucid.class_ "timeline-tool-result"] <| do
- Lucid.summary_ <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "📄"
- Lucid.span_ [Lucid.class_ "event-label"] "Result"
- when (lineCount > 1)
- <| Lucid.span_ [Lucid.class_ "line-count"] (Lucid.toHtml (tshow lineCount <> " lines"))
- renderRelativeTimestamp now timestamp
- Lucid.pre_ [Lucid.class_ "event-content tool-output"] (renderDecodedToolResult content)
-
--- | Render checkpoint event
-renderCheckpointEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderCheckpointEvent content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-checkpoint"] <| do
- Lucid.div_ [Lucid.class_ "event-header"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "📍"
- Lucid.span_ [Lucid.class_ "event-label"] "Checkpoint"
- renderActorLabel actor
- renderRelativeTimestamp now timestamp
- Lucid.div_ [Lucid.class_ "event-content checkpoint-content"] (Lucid.toHtml content)
-
--- | Render guardrail event
-renderGuardrailEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderGuardrailEvent content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-guardrail"] <| do
- Lucid.div_ [Lucid.class_ "event-header"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "⚠️"
- Lucid.span_ [Lucid.class_ "event-label"] "Guardrail"
- renderActorLabel actor
- renderRelativeTimestamp now timestamp
- Lucid.div_ [Lucid.class_ "event-content guardrail-content"] (Lucid.toHtml content)
-
--- | Render generic/unknown event
-renderGenericEvent :: (Monad m) => Text -> Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
-renderGenericEvent eventType content actor timestamp now =
- Lucid.div_ [Lucid.class_ "timeline-generic"] <| do
- Lucid.div_ [Lucid.class_ "event-header"] <| do
- Lucid.span_ [Lucid.class_ "event-icon"] "📝"
- Lucid.span_ [Lucid.class_ "event-label"] (Lucid.toHtml eventType)
- renderActorLabel actor
- renderRelativeTimestamp now timestamp
- unless (Text.null content) <| Lucid.div_ [Lucid.class_ "event-content"] (Lucid.toHtml content)
-
-parseToolCallContent :: Text -> (Text, Text)
-parseToolCallContent content =
- case Text.breakOn ":" content of
- (name, rest)
- | Text.null rest -> (content, "")
- | otherwise -> (Text.strip name, Text.strip (Text.drop 1 rest))
-
-formatToolCallSummary :: Text -> Text -> Text
-formatToolCallSummary toolName argsJson =
- case Aeson.decode (LBS.fromStrict (str argsJson)) of
- Just (Aeson.Object obj) ->
- let keyArg = case toolName of
- "run_bash" -> KeyMap.lookup "command" obj
- "read_file" -> KeyMap.lookup "path" obj
- "edit_file" -> KeyMap.lookup "path" obj
- "write_file" -> KeyMap.lookup "path" obj
- "search_codebase" -> KeyMap.lookup "pattern" obj
- "glob_files" -> KeyMap.lookup "pattern" obj
- "list_directory" -> KeyMap.lookup "path" obj
- _ -> Nothing
- in case keyArg of
- Just (Aeson.String s) -> "`" <> Text.take 100 s <> "`"
- _ -> Text.take 80 argsJson
- _ -> Text.take 80 argsJson
-
-renderCollapsibleOutput :: (Monad m) => Text -> Lucid.HtmlT m ()
-renderCollapsibleOutput content =
- let lineCount = length (Text.lines content)
- in if lineCount > 20
- then
- Lucid.details_ [Lucid.class_ "output-collapsible"] <| do
- Lucid.summary_ (Lucid.toHtml (tshow lineCount <> " lines"))
- Lucid.pre_ [Lucid.class_ "tool-output-pre"] (Lucid.toHtml content)
- else Lucid.pre_ [Lucid.class_ "tool-output-pre"] (Lucid.toHtml content)
-
--- | Decode JSON tool result and render in a user-friendly way
-renderDecodedToolResult :: (Monad m) => Text -> Lucid.HtmlT m ()
-renderDecodedToolResult content =
- case Aeson.decode (LBS.fromStrict (str content)) of
- Just (Aeson.Object obj) ->
- case KeyMap.lookup "output" obj of
- Just (Aeson.String output) -> Lucid.toHtml output
- _ -> Lucid.toHtml content -- Fallback to raw if no output field
- _ -> Lucid.toHtml content -- Fallback to raw if not JSON
-
-timelineScrollScript :: (Monad m) => Lucid.HtmlT m ()
-timelineScrollScript =
- Lucid.script_
- [ Lucid.type_ "text/javascript"
- ]
- ( Text.unlines
- [ "(function() {",
- " var log = document.querySelector('.timeline-events');",
- " if (log) {",
- " var isNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 100;",
- " if (isNearBottom) {",
- " log.scrollTop = log.scrollHeight;",
- " }",
- " }",
- "})();"
- ]
- )
-
-instance Lucid.ToHtml AgentEventsPartial where
- toHtmlRaw = Lucid.toHtml
- toHtml (AgentEventsPartial events isInProgress now) = do
- Lucid.h3_ <| do
- Lucid.toHtml ("Timeline (" <> tshow (length events) <> ")")
- when isInProgress <| Lucid.span_ [Lucid.class_ "timeline-live"] " LIVE"
- if null events
- then Lucid.p_ [Lucid.class_ "empty-msg"] "No activity yet."
- else do
- Lucid.div_ [Lucid.class_ "timeline-events"] <| do
- traverse_ (renderTimelineEvent now) events
- timelineScrollScript
-
--- | Stream agent events as SSE
-streamAgentEvents :: Text -> Text -> IO (SourceIO ByteString)
-streamAgentEvents tid sid = do
- -- Get existing events first
- existingEvents <- TaskCore.getEventsForSession sid
- let lastId = if null existingEvents then 0 else maximum (map TaskCore.storedEventId existingEvents)
-
- -- Convert existing events to SSE format
- let existingSSE = map eventToSSE existingEvents
-
- -- Create a streaming source that sends existing events, then polls for new ones
- pure <| Source.fromStepT <| streamEventsStep tid sid lastId existingSSE True
-
--- | Step function for streaming events
-streamEventsStep :: Text -> Text -> Int -> [ByteString] -> Bool -> Source.StepT IO ByteString
-streamEventsStep tid sid lastId buffer sendExisting = case (sendExisting, buffer) of
- -- Send buffered existing events first
- (True, b : bs) -> Source.Yield b (streamEventsStep tid sid lastId bs True)
- (True, []) -> streamEventsStep tid sid lastId [] False
- -- Poll for new events
- (False, _) ->
- Source.Effect <| do
- -- Check if task is still in progress
- tasks <- TaskCore.loadTasks
- let isComplete = case TaskCore.findTask tid tasks of
- Nothing -> True
- Just task -> TaskCore.taskStatus task /= TaskCore.InProgress
-
- if isComplete
- then do
- -- Send complete event and stop
- let completeSSE = formatSSE "complete" "{}"
- pure <| Source.Yield completeSSE Source.Stop
- else do
- -- Poll for new events
- Concurrent.threadDelay 500000 -- 500ms
- newEvents <- TaskCore.getEventsSince sid lastId
- if null newEvents
- then pure <| streamEventsStep tid sid lastId [] False
- else do
- let newLastId = maximum (map TaskCore.storedEventId newEvents)
- let newSSE = map eventToSSE newEvents
- case newSSE of
- (e : es) -> pure <| Source.Yield e (streamEventsStep tid sid newLastId es False)
- [] -> pure <| streamEventsStep tid sid newLastId [] False
-
--- | Convert a StoredEvent to SSE format
-eventToSSE :: TaskCore.StoredEvent -> ByteString
-eventToSSE event =
- let eventType = Text.toLower (TaskCore.storedEventType event)
- content = TaskCore.storedEventContent event
- jsonData = case eventType of
- "assistant" -> Aeson.object ["content" Aeson..= content]
- "toolcall" ->
- let (tool, args) = parseToolCallContent content
- in Aeson.object ["tool" Aeson..= tool, "args" Aeson..= Aeson.object ["data" Aeson..= args]]
- "toolresult" ->
- Aeson.object ["tool" Aeson..= ("unknown" :: Text), "success" Aeson..= True, "output" Aeson..= content]
- "cost" -> Aeson.object ["cost" Aeson..= content]
- "error" -> Aeson.object ["error" Aeson..= content]
- "complete" -> Aeson.object []
- _ -> Aeson.object ["content" Aeson..= content]
- in formatSSE eventType (str (Aeson.encode jsonData))
-
--- | Format an SSE message
-formatSSE :: Text -> ByteString -> ByteString
-formatSSE eventType jsonData =
- str
- <| "event: "
- <> eventType
- <> "\n"
- <> "data: "
- <> str jsonData
- <> "\n\n"
-
-api :: Proxy API
-api = Proxy
-
-server :: Server API
-server =
- homeHandler
- :<|> styleHandler
- :<|> readyQueueHandler
- :<|> blockedHandler
- :<|> interventionHandler
- :<|> statsHandler
- :<|> taskListHandler
- :<|> kbHandler
- :<|> factCreateHandler
- :<|> factDetailHandler
- :<|> factEditHandler
- :<|> factDeleteHandler
- :<|> epicsHandler
- :<|> taskDetailHandler
- :<|> taskStatusHandler
- :<|> taskPriorityHandler
- :<|> descriptionViewHandler
- :<|> descriptionEditHandler
- :<|> descriptionPostHandler
- :<|> taskNotesHandler
- :<|> taskCommentHandler
- :<|> taskReviewHandler
- :<|> taskDiffHandler
- :<|> taskAcceptHandler
- :<|> taskRejectHandler
- :<|> taskResetRetriesHandler
- :<|> recentActivityNewHandler
- :<|> recentActivityMoreHandler
- :<|> readyCountHandler
- :<|> taskListPartialHandler
- :<|> taskMetricsPartialHandler
- :<|> agentEventsPartialHandler
- :<|> taskEventsStreamHandler
- where
- styleHandler :: Servant.Handler LazyText.Text
- styleHandler = pure Style.css
-
- homeHandler :: Maybe Text -> Servant.Handler HomePage
- homeHandler maybeRangeText = do
- now <- liftIO getCurrentTime
- let range = parseTimeRange maybeRangeText
- maybeStart = getTimeRangeStart range now
- allTasks <- liftIO TaskCore.loadTasks
- let filteredTasks = case maybeStart of
- Nothing -> allTasks
- Just start -> [t | t <- allTasks, TaskCore.taskUpdatedAt t >= start]
- stats = TaskCore.computeTaskStatsFromList filteredTasks
- readyTasks <- liftIO TaskCore.getReadyTasks
- allActivities <- liftIO <| concat </ traverse (TaskCore.getActivitiesForTask <. TaskCore.taskId) allTasks
- let filteredActivities = case maybeStart of
- Nothing -> allActivities
- Just start -> [a | a <- allActivities, TaskCore.activityTimestamp a >= start]
- globalMetrics = computeMetricsFromActivities filteredTasks filteredActivities
- sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) filteredTasks
- recentTasks = take 5 sortedTasks
- hasMoreRecent = length filteredTasks > 5
- pure (HomePage stats readyTasks recentTasks hasMoreRecent globalMetrics range now)
-
- readyQueueHandler :: Maybe Text -> Servant.Handler ReadyQueuePage
- readyQueueHandler maybeSortText = do
- now <- liftIO getCurrentTime
- readyTasks <- liftIO TaskCore.getReadyTasks
- let sortOrder = parseSortOrder maybeSortText
- sortedTasks = sortTasks sortOrder readyTasks
- pure (ReadyQueuePage sortedTasks sortOrder now)
-
- blockedHandler :: Maybe Text -> Servant.Handler BlockedPage
- blockedHandler maybeSortText = do
- now <- liftIO getCurrentTime
- blockedTasks <- liftIO TaskCore.getBlockedTasks
- allTasks <- liftIO TaskCore.loadTasks
- let sortOrder = parseSortOrder maybeSortText
- tasksWithImpact = [(t, TaskCore.getBlockingImpact allTasks t) | t <- blockedTasks]
- sorted = List.sortBy (comparing (Down <. snd)) tasksWithImpact
- pure (BlockedPage sorted sortOrder now)
-
- interventionHandler :: Maybe Text -> Servant.Handler InterventionPage
- interventionHandler maybeSortText = do
- now <- liftIO getCurrentTime
- actionItems <- liftIO TaskCore.getHumanActionItems
- let sortOrder = parseSortOrder maybeSortText
- pure (InterventionPage actionItems sortOrder now)
-
- statsHandler :: Maybe Text -> Servant.Handler StatsPage
- statsHandler maybeEpic = do
- let epicId = emptyToNothing maybeEpic
- stats <- liftIO <| TaskCore.getTaskStats epicId
- pure (StatsPage stats epicId)
-
- taskListHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPage
- taskListHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText maybeSortText = do
- now <- liftIO getCurrentTime
- allTasks <- liftIO TaskCore.loadTasks
- let maybeStatus = parseStatus =<< emptyToNothing maybeStatusText
- maybePriority = parsePriority =<< emptyToNothing maybePriorityText
- maybeType = parseTaskType =<< emptyToNothing maybeTypeText
- filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) maybeType
- sortOrder = parseSortOrder maybeSortText
- filteredTasks = sortTasks sortOrder (applyFilters filters allTasks)
- pure (TaskListPage filteredTasks filters sortOrder now)
-
- kbHandler :: Servant.Handler KBPage
- kbHandler = do
- facts <- liftIO Fact.getAllFacts
- pure (KBPage facts)
-
- factCreateHandler :: FactCreateForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- factCreateHandler (FactCreateForm project content filesText confText) = do
- let files = filter (not <. Text.null) (Text.splitOn "," (Text.strip filesText))
- confidence = fromMaybe 0.8 (readMaybe (Text.unpack confText))
- fid <- liftIO (Fact.createFact project content files Nothing confidence)
- pure <| addHeader ("/kb/" <> tshow fid) NoContent
-
- factDetailHandler :: Int -> Servant.Handler FactDetailPage
- factDetailHandler fid = do
- now <- liftIO getCurrentTime
- maybeFact <- liftIO (Fact.getFact fid)
- case maybeFact of
- Nothing -> pure (FactDetailNotFound fid)
- Just fact -> pure (FactDetailFound fact now)
-
- factEditHandler :: Int -> FactEditForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- factEditHandler fid (FactEditForm content filesText confText) = do
- let files = filter (not <. Text.null) (Text.splitOn "," (Text.strip filesText))
- confidence = fromMaybe 0.8 (readMaybe (Text.unpack confText))
- liftIO (Fact.updateFact fid content files confidence)
- pure <| addHeader ("/kb/" <> tshow fid) NoContent
-
- factDeleteHandler :: Int -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- factDeleteHandler fid = do
- liftIO (Fact.deleteFact fid)
- pure <| addHeader "/kb" NoContent
-
- epicsHandler :: Maybe Text -> Servant.Handler EpicsPage
- epicsHandler maybeSortText = do
- allTasks <- liftIO TaskCore.loadTasks
- let epicTasks = filter (\t -> TaskCore.taskType t == TaskCore.Epic) allTasks
- sortOrder = parseSortOrder maybeSortText
- sortedEpics = sortTasks sortOrder epicTasks
- pure (EpicsPage sortedEpics allTasks sortOrder)
-
- parseStatus :: Text -> Maybe TaskCore.Status
- parseStatus = readMaybe <. Text.unpack
-
- parsePriority :: Text -> Maybe TaskCore.Priority
- parsePriority = readMaybe <. Text.unpack
-
- parseTaskType :: Text -> Maybe TaskCore.TaskType
- parseTaskType = readMaybe <. Text.unpack
-
- emptyToNothing :: Maybe Text -> Maybe Text
- emptyToNothing (Just t) | Text.null (Text.strip t) = Nothing
- emptyToNothing x = x
-
- applyFilters :: TaskFilters -> [TaskCore.Task] -> [TaskCore.Task]
- applyFilters filters = filter matchesAllFilters
- where
- matchesAllFilters task =
- matchesStatus task
- && matchesPriority task
- && matchesNamespace task
- && matchesType task
-
- matchesStatus task = case filterStatus filters of
- Nothing -> True
- Just s -> TaskCore.taskStatus task == s
-
- matchesPriority task = case filterPriority filters of
- Nothing -> True
- Just p -> TaskCore.taskPriority task == p
-
- matchesNamespace task = case filterNamespace filters of
- Nothing -> True
- Just ns -> case TaskCore.taskNamespace task of
- Nothing -> False
- Just taskNs -> ns `Text.isPrefixOf` taskNs
-
- matchesType task = case filterType filters of
- Nothing -> True
- Just t -> TaskCore.taskType task == t
-
- taskDetailHandler :: Text -> Servant.Handler TaskDetailPage
- taskDetailHandler tid = do
- now <- liftIO getCurrentTime
- tasks <- liftIO TaskCore.loadTasks
- case TaskCore.findTask tid tasks of
- Nothing -> pure (TaskDetailNotFound tid)
- Just task -> do
- activities <- liftIO (TaskCore.getActivitiesForTask tid)
- retryCtx <- liftIO (TaskCore.getRetryContext tid)
- commits <- liftIO (getCommitsForTask tid)
- aggMetrics <-
- if TaskCore.taskType task == TaskCore.Epic
- then Just </ liftIO (TaskCore.getAggregatedMetrics tid)
- else pure Nothing
- agentEvents <- liftIO (TaskCore.getAllEventsForTask tid)
- pure (TaskDetailFound task tasks activities retryCtx commits aggMetrics agentEvents now)
-
- taskStatusHandler :: Text -> StatusForm -> Servant.Handler StatusBadgePartial
- taskStatusHandler tid (StatusForm newStatus) = do
- liftIO <| TaskCore.updateTaskStatusWithActor tid newStatus [] TaskCore.Human
- pure (StatusBadgePartial newStatus tid)
-
- taskPriorityHandler :: Text -> PriorityForm -> Servant.Handler PriorityBadgePartial
- taskPriorityHandler tid (PriorityForm newPriority) = do
- _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskPriority = newPriority})
- pure (PriorityBadgePartial newPriority tid)
-
- descriptionViewHandler :: Text -> Servant.Handler DescriptionViewPartial
- descriptionViewHandler tid = do
- tasks <- liftIO TaskCore.loadTasks
- case TaskCore.findTask tid tasks of
- Nothing -> throwError err404
- Just task -> pure (DescriptionViewPartial tid (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
-
- descriptionEditHandler :: Text -> Servant.Handler DescriptionEditPartial
- descriptionEditHandler tid = do
- tasks <- liftIO TaskCore.loadTasks
- case TaskCore.findTask tid tasks of
- Nothing -> throwError err404
- Just task -> pure (DescriptionEditPartial tid (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
-
- descriptionPostHandler :: Text -> DescriptionForm -> Servant.Handler DescriptionViewPartial
- descriptionPostHandler tid (DescriptionForm desc) = do
- let descText = Text.strip desc
- _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskDescription = descText})
- tasks <- liftIO TaskCore.loadTasks
- case TaskCore.findTask tid tasks of
- Nothing -> throwError err404
- Just task -> pure (DescriptionViewPartial tid (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
-
- taskNotesHandler :: Text -> NotesForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- taskNotesHandler tid (NotesForm notes) = do
- liftIO <| TaskCore.updateRetryNotes tid notes
- pure <| addHeader ("/tasks/" <> tid) NoContent
-
- taskCommentHandler :: Text -> CommentForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- taskCommentHandler tid (CommentForm commentText) = do
- _ <- liftIO (TaskCore.addComment tid commentText TaskCore.Human)
- pure <| addHeader ("/tasks/" <> tid) NoContent
-
- taskReviewHandler :: Text -> Servant.Handler TaskReviewPage
- taskReviewHandler tid = do
- tasks <- liftIO TaskCore.loadTasks
- case TaskCore.findTask tid tasks of
- Nothing -> pure (ReviewPageNotFound tid)
- Just task -> do
- reviewInfo <- liftIO <| getReviewInfo tid
- pure (ReviewPageFound task reviewInfo)
-
- taskDiffHandler :: Text -> Text -> Servant.Handler TaskDiffPage
- taskDiffHandler tid commitSha = do
- diffOutput <- liftIO <| getDiffForCommit commitSha
- case diffOutput of
- Nothing -> pure (DiffPageNotFound tid commitSha)
- Just output -> pure (DiffPageFound tid commitSha output)
-
- taskAcceptHandler :: Text -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- taskAcceptHandler tid = do
- liftIO <| do
- TaskCore.clearRetryContext tid
- TaskCore.updateTaskStatusWithActor tid TaskCore.Done [] TaskCore.Human
- pure <| addHeader ("/tasks/" <> tid) NoContent
-
- taskRejectHandler :: Text -> RejectForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- taskRejectHandler tid (RejectForm maybeNotes) = do
- liftIO <| do
- maybeCommit <- findCommitForTask tid
- let commitSha = fromMaybe "" maybeCommit
- maybeCtx <- TaskCore.getRetryContext tid
- let attempt = maybe 1 (\ctx -> TaskCore.retryAttempt ctx + 1) maybeCtx
- let currentReason = "attempt " <> tshow attempt <> ": rejected: " <> fromMaybe "(no notes)" maybeNotes
- let accumulatedReason = case maybeCtx of
- Nothing -> currentReason
- Just ctx -> TaskCore.retryReason ctx <> "\n" <> currentReason
- TaskCore.setRetryContext
- TaskCore.RetryContext
- { TaskCore.retryTaskId = tid,
- TaskCore.retryOriginalCommit = commitSha,
- TaskCore.retryConflictFiles = [],
- TaskCore.retryAttempt = attempt,
- TaskCore.retryReason = accumulatedReason,
- TaskCore.retryNotes = maybeCtx +> TaskCore.retryNotes
- }
- TaskCore.updateTaskStatusWithActor tid TaskCore.Open [] TaskCore.Human
- pure <| addHeader ("/tasks/" <> tid) NoContent
-
- taskResetRetriesHandler :: Text -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
- taskResetRetriesHandler tid = do
- liftIO <| do
- TaskCore.clearRetryContext tid
- TaskCore.updateTaskStatusWithActor tid TaskCore.Open [] TaskCore.Human
- pure <| addHeader ("/tasks/" <> tid) NoContent
-
- recentActivityNewHandler :: Maybe Int -> Servant.Handler RecentActivityNewPartial
- recentActivityNewHandler maybeSince = do
- allTasks <- liftIO TaskCore.loadTasks
- let sinceTime = maybe (posixSecondsToUTCTime 0) (posixSecondsToUTCTime <. fromIntegral) maybeSince
- sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) allTasks
- newTasks = filter (\t -> TaskCore.taskUpdatedAt t > sinceTime) sortedTasks
- newestTs = maybe maybeSince (Just <. taskToUnixTs) (head newTasks)
- pure (RecentActivityNewPartial newTasks newestTs)
-
- recentActivityMoreHandler :: Maybe Int -> Servant.Handler RecentActivityMorePartial
- recentActivityMoreHandler maybeOffset = do
- allTasks <- liftIO TaskCore.loadTasks
- let offset = fromMaybe 0 maybeOffset
- pageSize = 5
- sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) allTasks
- pageTasks = take pageSize <| drop offset sortedTasks
- hasMore = length sortedTasks > offset + pageSize
- nextOffset = offset + pageSize
- pure (RecentActivityMorePartial pageTasks nextOffset hasMore)
-
- readyCountHandler :: Servant.Handler ReadyCountPartial
- readyCountHandler = do
- readyTasks <- liftIO TaskCore.getReadyTasks
- pure (ReadyCountPartial (length readyTasks))
-
- taskListPartialHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPartial
- taskListPartialHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText maybeSortText = do
- allTasks <- liftIO TaskCore.loadTasks
- let maybeStatus = parseStatus =<< emptyToNothing maybeStatusText
- maybePriority = parsePriority =<< emptyToNothing maybePriorityText
- maybeType = parseTaskType =<< emptyToNothing maybeTypeText
- filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) maybeType
- sortOrder = parseSortOrder maybeSortText
- filteredTasks = sortTasks sortOrder (applyFilters filters allTasks)
- pure (TaskListPartial filteredTasks)
-
- taskMetricsPartialHandler :: Text -> Servant.Handler TaskMetricsPartial
- taskMetricsPartialHandler tid = do
- now <- liftIO getCurrentTime
- activities <- liftIO (TaskCore.getActivitiesForTask tid)
- maybeRetry <- liftIO (TaskCore.getRetryContext tid)
- pure (TaskMetricsPartial tid activities maybeRetry now)
-
- agentEventsPartialHandler :: Text -> Maybe Int -> Servant.Handler AgentEventsPartial
- agentEventsPartialHandler tid _maybeSince = do
- now <- liftIO getCurrentTime
- events <- liftIO (TaskCore.getAllEventsForTask tid)
- tasks <- liftIO TaskCore.loadTasks
- let isInProgress = case TaskCore.findTask tid tasks of
- Nothing -> False
- Just task -> TaskCore.taskStatus task == TaskCore.InProgress
- pure (AgentEventsPartial events isInProgress now)
-
- taskEventsStreamHandler :: Text -> Servant.Handler (SourceIO ByteString)
- taskEventsStreamHandler tid = do
- maybeSession <- liftIO (TaskCore.getLatestSessionForTask tid)
- case maybeSession of
- Nothing -> pure (Source.source [])
- Just sid -> liftIO (streamAgentEvents tid sid)
-
-taskToUnixTs :: TaskCore.Task -> Int
-taskToUnixTs t = ceiling (utcTimeToPOSIXSeconds (TaskCore.taskUpdatedAt t))
-
-getReviewInfo :: Text -> IO ReviewInfo
-getReviewInfo tid = do
- maybeCommit <- findCommitForTask tid
- case maybeCommit of
- Nothing -> pure ReviewNoCommit
- Just commitSha -> do
- conflictResult <- checkMergeConflict (Text.unpack commitSha)
- case conflictResult of
- Just conflictFiles -> pure (ReviewMergeConflict commitSha conflictFiles)
- Nothing -> do
- (_, diffOut, _) <-
- Process.readProcessWithExitCode
- "git"
- ["show", Text.unpack commitSha]
- ""
- pure (ReviewReady commitSha (Text.pack diffOut))
-
-getDiffForCommit :: Text -> IO (Maybe Text)
-getDiffForCommit commitSha = do
- (code, diffOut, _) <-
- Process.readProcessWithExitCode
- "git"
- ["show", Text.unpack commitSha]
- ""
- case code of
- Exit.ExitSuccess -> pure (Just (Text.pack diffOut))
- Exit.ExitFailure _ -> pure Nothing
-
-findCommitForTask :: Text -> IO (Maybe Text)
-findCommitForTask tid = do
- let grepArg = "--grep=" <> Text.unpack tid
- (code, shaOut, _) <-
- Process.readProcessWithExitCode
- "git"
- ["log", "--pretty=format:%H", "-n", "1", grepArg]
- ""
- if code /= Exit.ExitSuccess || null shaOut
- then pure Nothing
- else case List.lines shaOut of
- (x : _) -> pure (Just (Text.pack x))
- [] -> pure Nothing
-
-getCommitsForTask :: Text -> IO [GitCommit]
-getCommitsForTask tid = do
- let grepArg = "--grep=Task-Id: " <> Text.unpack tid
- (code, out, _) <-
- Process.readProcessWithExitCode
- "git"
- ["log", "--pretty=format:%H|%h|%s|%an|%ar", grepArg]
- ""
- if code /= Exit.ExitSuccess || null out
- then pure []
- else do
- let commitLines = filter (not <. null) (List.lines out)
- traverse parseCommitLine commitLines
- where
- parseCommitLine :: String -> IO GitCommit
- parseCommitLine line =
- case Text.splitOn "|" (Text.pack line) of
- [sha, shortSha, summary, author, relDate] -> do
- filesCount <- getFilesChangedCount (Text.unpack sha)
- pure
- GitCommit
- { commitHash = sha,
- commitShortHash = shortSha,
- commitSummary = summary,
- commitAuthor = author,
- commitRelativeDate = relDate,
- commitFilesChanged = filesCount
- }
- _ ->
- pure
- GitCommit
- { commitHash = Text.pack line,
- commitShortHash = Text.take 7 (Text.pack line),
- commitSummary = "(parse error)",
- commitAuthor = "",
- commitRelativeDate = "",
- commitFilesChanged = 0
- }
-
- getFilesChangedCount :: String -> IO Int
- getFilesChangedCount sha = do
- (code', out', _) <-
- Process.readProcessWithExitCode
- "git"
- ["show", "--stat", "--format=", sha]
- ""
- pure
- <| if code' /= Exit.ExitSuccess
- then 0
- else
- let statLines = filter (not <. null) (List.lines out')
- in max 0 (length statLines - 1)
-
-checkMergeConflict :: String -> IO (Maybe [Text])
-checkMergeConflict commitSha = do
- (_, origHead, _) <- Process.readProcessWithExitCode "git" ["rev-parse", "HEAD"] ""
-
- (cpCode, _, cpErr) <-
- Process.readProcessWithExitCode
- "git"
- ["cherry-pick", "--no-commit", commitSha]
- ""
-
- _ <- Process.readProcessWithExitCode "git" ["cherry-pick", "--abort"] ""
- _ <- Process.readProcessWithExitCode "git" ["reset", "--hard", List.head (List.lines origHead)] ""
-
- case cpCode of
- Exit.ExitSuccess -> pure Nothing
- Exit.ExitFailure _ -> do
- let errLines = Text.lines (Text.pack cpErr)
- conflictLines = filter (Text.isPrefixOf "CONFLICT") errLines
- files = mapMaybe extractConflictFile conflictLines
- pure (Just (if null files then ["(unknown files)"] else files))
-
-extractConflictFile :: Text -> Maybe Text
-extractConflictFile line =
- case Text.breakOn "Merge conflict in " line of
- (_, rest)
- | not (Text.null rest) -> Just (Text.strip (Text.drop 18 rest))
- _ -> case Text.breakOn "in " line of
- (_, rest)
- | not (Text.null rest) -> Just (Text.strip (Text.drop 3 rest))
- _ -> Nothing
-
-app :: Application
-app = serve api server
-
run :: Warp.Port -> IO ()
run port = do
- TaskCore.initTaskDb
putText <| "Starting Jr web server on port " <> tshow port
- Warp.run port app
+ Warp.run port (serve api server)
diff --git a/Omni/Jr/Web/Components.hs b/Omni/Jr/Web/Components.hs
new file mode 100644
index 0000000..9c32cf2
--- /dev/null
+++ b/Omni/Jr/Web/Components.hs
@@ -0,0 +1,1464 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- : dep lucid
+-- : dep servant-lucid
+module Omni.Jr.Web.Components
+ ( -- * Time formatting
+ formatRelativeTime,
+ relativeText,
+ formatExactTimestamp,
+ renderRelativeTimestamp,
+
+ -- * Small components
+ metaSep,
+
+ -- * Page layout
+ pageHead,
+ pageBody,
+ pageBodyWithCrumbs,
+ navbar,
+
+ -- * JavaScript
+ navbarDropdownJs,
+ statusDropdownJs,
+ priorityDropdownJs,
+ liveToggleJs,
+
+ -- * Breadcrumbs
+ Breadcrumb (..),
+ Breadcrumbs,
+ renderBreadcrumbs,
+ getAncestors,
+ taskBreadcrumbs,
+
+ -- * Badges
+ statusBadge,
+ complexityBadge,
+ statusBadgeWithForm,
+ clickableBadge,
+ statusDropdownOptions,
+ statusOption,
+ priorityBadgeWithForm,
+ clickablePriorityBadge,
+ priorityDropdownOptions,
+ priorityOption,
+
+ -- * Sorting
+ SortOrder (..),
+ sortOrderToParam,
+ sortOrderLabel,
+ sortDropdown,
+ sortOption,
+
+ -- * Progress bars
+ multiColorProgressBar,
+ epicProgressBar,
+
+ -- * Task rendering
+ renderTaskCard,
+ renderBlockedTaskCard,
+ renderListGroupItem,
+ renderEpicReviewCard,
+ renderEpicCardWithStats,
+ getDescendants,
+
+ -- * Metrics
+ renderAggregatedMetrics,
+
+ -- * Retry context
+ renderRetryContextBanner,
+
+ -- * Markdown
+ MarkdownBlock (..),
+ InlinePart (..),
+ renderMarkdown,
+ parseBlocks,
+ renderBlocks,
+ renderBlock,
+ renderListItem,
+ renderInline,
+ parseInline,
+ parseBold,
+ renderInlineParts,
+ renderInlinePart,
+
+ -- * Comments
+ commentForm,
+
+ -- * Live toggles
+ renderLiveToggle,
+ renderAutoscrollToggle,
+
+ -- * Cost/Token metrics
+ aggregateCostMetrics,
+ formatCostHeader,
+ formatTokensHeader,
+
+ -- * Timeline
+ renderUnifiedTimeline,
+ renderTimelineEvent,
+ eventTypeIconAndLabel,
+ renderActorLabel,
+ renderCommentTimelineEvent,
+ renderStatusChangeEvent,
+ parseStatusChange,
+ renderActivityEvent,
+ renderErrorTimelineEvent,
+ renderAssistantTimelineEvent,
+ renderToolCallTimelineEvent,
+ renderToolResultTimelineEvent,
+ renderCheckpointEvent,
+ renderGuardrailEvent,
+ renderGenericEvent,
+ parseToolCallContent,
+ formatToolCallSummary,
+ renderCollapsibleOutput,
+ renderDecodedToolResult,
+ timelineScrollScript,
+ )
+where
+
+import Alpha
+import qualified Data.Aeson as Aeson
+import qualified Data.Aeson.KeyMap as KeyMap
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.List as List
+import qualified Data.Text as Text
+import Data.Time (NominalDiffTime, UTCTime, defaultTimeLocale, diffUTCTime, formatTime)
+import qualified Lucid
+import qualified Lucid.Base as Lucid
+import Numeric (showFFloat)
+import qualified Omni.Task.Core as TaskCore
+
+-- * Time formatting
+
+formatRelativeTime :: UTCTime -> UTCTime -> Text
+formatRelativeTime now timestamp =
+ let delta = diffUTCTime now timestamp
+ in relativeText delta
+
+relativeText :: NominalDiffTime -> Text
+relativeText delta
+ | delta < 60 = "just now"
+ | delta < 3600 = tshow (round (delta / 60) :: Int) <> " minutes ago"
+ | delta < 7200 = "1 hour ago"
+ | delta < 86400 = tshow (round (delta / 3600) :: Int) <> " hours ago"
+ | delta < 172800 = "yesterday"
+ | delta < 604800 = tshow (round (delta / 86400) :: Int) <> " days ago"
+ | delta < 1209600 = "1 week ago"
+ | delta < 2592000 = tshow (round (delta / 604800) :: Int) <> " weeks ago"
+ | delta < 5184000 = "1 month ago"
+ | delta < 31536000 = tshow (round (delta / 2592000) :: Int) <> " months ago"
+ | otherwise = tshow (round (delta / 31536000) :: Int) <> " years ago"
+
+formatExactTimestamp :: UTCTime -> Text
+formatExactTimestamp = Text.pack <. formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S UTC"
+
+renderRelativeTimestamp :: (Monad m) => UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderRelativeTimestamp now timestamp =
+ Lucid.span_
+ [ Lucid.class_ "relative-time",
+ Lucid.title_ (formatExactTimestamp timestamp)
+ ]
+ (Lucid.toHtml (formatRelativeTime now timestamp))
+
+-- * Small components
+
+metaSep :: (Monad m) => Lucid.HtmlT m ()
+metaSep = Lucid.span_ [Lucid.class_ "meta-sep"] "·"
+
+-- * Sort types
+
+data SortOrder
+ = SortNewest
+ | SortOldest
+ | SortUpdated
+ | SortPriorityHigh
+ | SortPriorityLow
+ deriving (Show, Eq)
+
+sortOrderToParam :: SortOrder -> Text
+sortOrderToParam SortNewest = "newest"
+sortOrderToParam SortOldest = "oldest"
+sortOrderToParam SortUpdated = "updated"
+sortOrderToParam SortPriorityHigh = "priority-high"
+sortOrderToParam SortPriorityLow = "priority-low"
+
+sortOrderLabel :: SortOrder -> Text
+sortOrderLabel SortNewest = "Newest First"
+sortOrderLabel SortOldest = "Oldest First"
+sortOrderLabel SortUpdated = "Recently Updated"
+sortOrderLabel SortPriorityHigh = "Priority (High to Low)"
+sortOrderLabel SortPriorityLow = "Priority (Low to High)"
+
+-- * Page layout
+
+pageHead :: (Monad m) => Text -> Lucid.HtmlT m ()
+pageHead title =
+ Lucid.head_ <| do
+ Lucid.title_ (Lucid.toHtml title)
+ Lucid.meta_ [Lucid.charset_ "utf-8"]
+ Lucid.meta_
+ [ Lucid.name_ "viewport",
+ Lucid.content_ "width=device-width, initial-scale=1"
+ ]
+ Lucid.link_ [Lucid.rel_ "stylesheet", Lucid.href_ "/style.css"]
+ Lucid.script_
+ [ Lucid.src_ "https://unpkg.com/htmx.org@2.0.4",
+ Lucid.integrity_ "sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+",
+ Lucid.crossorigin_ "anonymous"
+ ]
+ ("" :: Text)
+ Lucid.script_ [] statusDropdownJs
+ Lucid.script_ [] priorityDropdownJs
+ Lucid.script_ [] navbarDropdownJs
+ Lucid.script_ [] liveToggleJs
+
+-- * JavaScript
+
+navbarDropdownJs :: Text
+navbarDropdownJs =
+ Text.unlines
+ [ "document.addEventListener('DOMContentLoaded', function() {",
+ " document.querySelectorAll('.navbar-dropdown-btn').forEach(function(btn) {",
+ " btn.addEventListener('click', function(e) {",
+ " e.preventDefault();",
+ " var dropdown = btn.closest('.navbar-dropdown');",
+ " var isOpen = dropdown.classList.contains('open');",
+ " document.querySelectorAll('.navbar-dropdown.open').forEach(function(d) {",
+ " d.classList.remove('open');",
+ " });",
+ " if (!isOpen) {",
+ " dropdown.classList.add('open');",
+ " }",
+ " });",
+ " });",
+ " document.addEventListener('click', function(e) {",
+ " if (!e.target.closest('.navbar-dropdown')) {",
+ " document.querySelectorAll('.navbar-dropdown.open').forEach(function(d) {",
+ " d.classList.remove('open');",
+ " });",
+ " }",
+ " });",
+ "});"
+ ]
+
+statusDropdownJs :: Text
+statusDropdownJs =
+ Text.unlines
+ [ "function toggleStatusDropdown(el) {",
+ " var container = el.parentElement;",
+ " var isOpen = container.classList.toggle('open');",
+ " el.setAttribute('aria-expanded', isOpen);",
+ " if (isOpen) {",
+ " var firstItem = container.querySelector('[role=\"menuitem\"]');",
+ " if (firstItem) firstItem.focus();",
+ " }",
+ "}",
+ "",
+ "function closeStatusDropdown(container) {",
+ " container.classList.remove('open');",
+ " var badge = container.querySelector('[role=\"button\"]');",
+ " if (badge) {",
+ " badge.setAttribute('aria-expanded', 'false');",
+ " badge.focus();",
+ " }",
+ "}",
+ "",
+ "function handleStatusKeydown(event, el) {",
+ " if (event.key === 'Enter' || event.key === ' ') {",
+ " event.preventDefault();",
+ " toggleStatusDropdown(el);",
+ " } else if (event.key === 'Escape') {",
+ " closeStatusDropdown(el.parentElement);",
+ " } else if (event.key === 'ArrowDown') {",
+ " event.preventDefault();",
+ " var container = el.parentElement;",
+ " if (!container.classList.contains('open')) {",
+ " toggleStatusDropdown(el);",
+ " } else {",
+ " var firstItem = container.querySelector('[role=\"menuitem\"]');",
+ " if (firstItem) firstItem.focus();",
+ " }",
+ " }",
+ "}",
+ "",
+ "function handleMenuItemKeydown(event) {",
+ " var container = event.target.closest('.status-badge-dropdown');",
+ " var items = container.querySelectorAll('[role=\"menuitem\"]');",
+ " var currentIndex = Array.from(items).indexOf(event.target);",
+ " ",
+ " if (event.key === 'ArrowDown') {",
+ " event.preventDefault();",
+ " var next = (currentIndex + 1) % items.length;",
+ " items[next].focus();",
+ " } else if (event.key === 'ArrowUp') {",
+ " event.preventDefault();",
+ " var prev = (currentIndex - 1 + items.length) % items.length;",
+ " items[prev].focus();",
+ " } else if (event.key === 'Escape') {",
+ " event.preventDefault();",
+ " closeStatusDropdown(container);",
+ " } else if (event.key === 'Tab') {",
+ " closeStatusDropdown(container);",
+ " }",
+ "}",
+ "",
+ "document.addEventListener('click', function(e) {",
+ " var dropdowns = document.querySelectorAll('.status-badge-dropdown.open');",
+ " dropdowns.forEach(function(d) {",
+ " if (!d.contains(e.target)) {",
+ " closeStatusDropdown(d);",
+ " }",
+ " });",
+ "});"
+ ]
+
+priorityDropdownJs :: Text
+priorityDropdownJs =
+ Text.unlines
+ [ "function togglePriorityDropdown(el) {",
+ " var container = el.parentElement;",
+ " var isOpen = container.classList.toggle('open');",
+ " el.setAttribute('aria-expanded', isOpen);",
+ " if (isOpen) {",
+ " var firstItem = container.querySelector('[role=\"menuitem\"]');",
+ " if (firstItem) firstItem.focus();",
+ " }",
+ "}",
+ "",
+ "function closePriorityDropdown(container) {",
+ " container.classList.remove('open');",
+ " var badge = container.querySelector('[role=\"button\"]');",
+ " if (badge) {",
+ " badge.setAttribute('aria-expanded', 'false');",
+ " badge.focus();",
+ " }",
+ "}",
+ "",
+ "function handlePriorityKeydown(event, el) {",
+ " if (event.key === 'Enter' || event.key === ' ') {",
+ " event.preventDefault();",
+ " togglePriorityDropdown(el);",
+ " } else if (event.key === 'Escape') {",
+ " closePriorityDropdown(el.parentElement);",
+ " } else if (event.key === 'ArrowDown') {",
+ " event.preventDefault();",
+ " var container = el.parentElement;",
+ " if (!container.classList.contains('open')) {",
+ " togglePriorityDropdown(el);",
+ " } else {",
+ " var firstItem = container.querySelector('[role=\"menuitem\"]');",
+ " if (firstItem) firstItem.focus();",
+ " }",
+ " }",
+ "}",
+ "",
+ "function handlePriorityMenuItemKeydown(event) {",
+ " var container = event.target.closest('.priority-badge-dropdown');",
+ " var items = container.querySelectorAll('[role=\"menuitem\"]');",
+ " var currentIndex = Array.from(items).indexOf(event.target);",
+ " ",
+ " if (event.key === 'ArrowDown') {",
+ " event.preventDefault();",
+ " var next = (currentIndex + 1) % items.length;",
+ " items[next].focus();",
+ " } else if (event.key === 'ArrowUp') {",
+ " event.preventDefault();",
+ " var prev = (currentIndex - 1 + items.length) % items.length;",
+ " items[prev].focus();",
+ " } else if (event.key === 'Escape') {",
+ " event.preventDefault();",
+ " closePriorityDropdown(container);",
+ " } else if (event.key === 'Tab') {",
+ " closePriorityDropdown(container);",
+ " }",
+ "}",
+ "",
+ "document.addEventListener('click', function(e) {",
+ " var dropdowns = document.querySelectorAll('.priority-badge-dropdown.open');",
+ " dropdowns.forEach(function(d) {",
+ " if (!d.contains(e.target)) {",
+ " closePriorityDropdown(d);",
+ " }",
+ " });",
+ "});"
+ ]
+
+liveToggleJs :: Text
+liveToggleJs =
+ Text.unlines
+ [ "var liveUpdatesEnabled = true;",
+ "var autoscrollEnabled = true;",
+ "",
+ "function toggleLiveUpdates() {",
+ " liveUpdatesEnabled = !liveUpdatesEnabled;",
+ " var btn = document.getElementById('live-toggle');",
+ " if (btn) {",
+ " btn.classList.toggle('timeline-live-paused', !liveUpdatesEnabled);",
+ " }",
+ "}",
+ "",
+ "function toggleAutoscroll() {",
+ " autoscrollEnabled = !autoscrollEnabled;",
+ " var btn = document.getElementById('autoscroll-toggle');",
+ " if (btn) {",
+ " btn.classList.toggle('timeline-autoscroll-disabled', !autoscrollEnabled);",
+ " }",
+ "}",
+ "",
+ "document.body.addEventListener('htmx:beforeRequest', function(evt) {",
+ " var timeline = document.getElementById('unified-timeline');",
+ " if (timeline && timeline.contains(evt.target) && !liveUpdatesEnabled) {",
+ " evt.preventDefault();",
+ " }",
+ "});",
+ "",
+ "document.body.addEventListener('htmx:afterSettle', function(evt) {",
+ " if (autoscrollEnabled) {",
+ " var log = document.querySelector('.timeline-events');",
+ " if (log) {",
+ " log.scrollTop = log.scrollHeight;",
+ " }",
+ " }",
+ "});"
+ ]
+
+pageBody :: (Monad m) => Lucid.HtmlT m () -> Lucid.HtmlT m ()
+pageBody content =
+ Lucid.body_ [Lucid.makeAttribute "hx-boost" "true"] <| do
+ navbar
+ content
+
+-- * Breadcrumbs
+
+data Breadcrumb = Breadcrumb
+ { _crumbLabel :: Text,
+ _crumbHref :: Maybe Text
+ }
+
+type Breadcrumbs = [Breadcrumb]
+
+pageBodyWithCrumbs :: (Monad m) => Breadcrumbs -> Lucid.HtmlT m () -> Lucid.HtmlT m ()
+pageBodyWithCrumbs crumbs content =
+ Lucid.body_ [Lucid.makeAttribute "hx-boost" "true"] <| do
+ navbar
+ unless (null crumbs) <| do
+ Lucid.div_ [Lucid.class_ "breadcrumb-container"] <| do
+ Lucid.div_ [Lucid.class_ "container"] <| renderBreadcrumbs crumbs
+ content
+
+renderBreadcrumbs :: (Monad m) => Breadcrumbs -> Lucid.HtmlT m ()
+renderBreadcrumbs [] = pure ()
+renderBreadcrumbs crumbs =
+ Lucid.nav_ [Lucid.class_ "breadcrumbs", Lucid.makeAttribute "aria-label" "Breadcrumb"] <| do
+ Lucid.ol_ [Lucid.class_ "breadcrumb-list"] <| do
+ traverse_ renderCrumb (zip [0 ..] crumbs)
+ where
+ renderCrumb :: (Monad m') => (Int, Breadcrumb) -> Lucid.HtmlT m' ()
+ renderCrumb (idx, Breadcrumb label mHref) = do
+ Lucid.li_ [Lucid.class_ "breadcrumb-item"] <| do
+ when (idx > 0) <| Lucid.span_ [Lucid.class_ "breadcrumb-sep"] ">"
+ case mHref of
+ Just href -> Lucid.a_ [Lucid.href_ href] (Lucid.toHtml label)
+ Nothing -> Lucid.span_ [Lucid.class_ "breadcrumb-current"] (Lucid.toHtml label)
+
+getAncestors :: [TaskCore.Task] -> TaskCore.Task -> [TaskCore.Task]
+getAncestors allTasks task =
+ case TaskCore.taskParent task of
+ Nothing -> [task]
+ Just pid -> case TaskCore.findTask pid allTasks of
+ Nothing -> [task]
+ Just parent -> getAncestors allTasks parent ++ [task]
+
+taskBreadcrumbs :: [TaskCore.Task] -> TaskCore.Task -> Breadcrumbs
+taskBreadcrumbs allTasks task =
+ let ancestors = getAncestors allTasks task
+ taskCrumbs = [Breadcrumb (TaskCore.taskId t) (Just ("/tasks/" <> TaskCore.taskId t)) | t <- List.init ancestors]
+ currentCrumb = Breadcrumb (TaskCore.taskId task) Nothing
+ in [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks")]
+ ++ taskCrumbs
+ ++ [currentCrumb]
+
+-- * Navbar
+
+navbar :: (Monad m) => Lucid.HtmlT m ()
+navbar =
+ Lucid.nav_ [Lucid.class_ "navbar"] <| do
+ Lucid.a_ [Lucid.href_ "/", Lucid.class_ "navbar-brand"] "Junior"
+ Lucid.input_
+ [ Lucid.type_ "checkbox",
+ Lucid.id_ "navbar-toggle",
+ Lucid.class_ "navbar-toggle-checkbox"
+ ]
+ Lucid.label_
+ [ Lucid.for_ "navbar-toggle",
+ Lucid.class_ "navbar-hamburger"
+ ]
+ <| do
+ Lucid.span_ [Lucid.class_ "hamburger-line"] ""
+ Lucid.span_ [Lucid.class_ "hamburger-line"] ""
+ Lucid.span_ [Lucid.class_ "hamburger-line"] ""
+ Lucid.div_ [Lucid.class_ "navbar-links"] <| do
+ Lucid.a_ [Lucid.href_ "/", Lucid.class_ "navbar-link"] "Dashboard"
+ Lucid.div_ [Lucid.class_ "navbar-dropdown"] <| do
+ Lucid.button_ [Lucid.class_ "navbar-dropdown-btn"] "Tasks ▾"
+ Lucid.div_ [Lucid.class_ "navbar-dropdown-content"] <| do
+ Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "navbar-dropdown-item"] "Ready"
+ Lucid.a_ [Lucid.href_ "/blocked", Lucid.class_ "navbar-dropdown-item"] "Blocked"
+ Lucid.a_ [Lucid.href_ "/intervention", Lucid.class_ "navbar-dropdown-item"] "Human Action"
+ Lucid.a_ [Lucid.href_ "/tasks", Lucid.class_ "navbar-dropdown-item"] "All"
+ Lucid.div_ [Lucid.class_ "navbar-dropdown"] <| do
+ Lucid.button_ [Lucid.class_ "navbar-dropdown-btn"] "Plans ▾"
+ Lucid.div_ [Lucid.class_ "navbar-dropdown-content"] <| do
+ Lucid.a_ [Lucid.href_ "/epics", Lucid.class_ "navbar-dropdown-item"] "Epics"
+ Lucid.a_ [Lucid.href_ "/kb", Lucid.class_ "navbar-dropdown-item"] "KB"
+ Lucid.a_ [Lucid.href_ "/stats", Lucid.class_ "navbar-link"] "Stats"
+
+-- * Badges
+
+statusBadge :: (Monad m) => TaskCore.Status -> Lucid.HtmlT m ()
+statusBadge status =
+ let (cls, label) = case status of
+ TaskCore.Draft -> ("badge badge-draft", "Draft")
+ TaskCore.Open -> ("badge badge-open", "Open")
+ TaskCore.InProgress -> ("badge badge-inprogress", "In Progress")
+ TaskCore.Review -> ("badge badge-review", "Review")
+ TaskCore.Approved -> ("badge badge-approved", "Approved")
+ TaskCore.Done -> ("badge badge-done", "Done")
+ TaskCore.NeedsHelp -> ("badge badge-needshelp", "Needs Help")
+ in Lucid.span_ [Lucid.class_ cls] label
+
+complexityBadge :: (Monad m) => Int -> Lucid.HtmlT m ()
+complexityBadge complexity =
+ let cls = "badge badge-complexity badge-complexity-" <> tshow complexity
+ label = "ℂ " <> tshow complexity
+ in Lucid.span_ [Lucid.class_ cls, Lucid.title_ "Task Complexity (1-5)"] (Lucid.toHtml label)
+
+-- * Sort dropdown
+
+sortDropdown :: (Monad m) => Text -> SortOrder -> Lucid.HtmlT m ()
+sortDropdown basePath currentSort =
+ Lucid.div_ [Lucid.class_ "sort-dropdown"] <| do
+ Lucid.span_ [Lucid.class_ "sort-label"] "Sort:"
+ Lucid.div_ [Lucid.class_ "sort-dropdown-wrapper navbar-dropdown"] <| do
+ Lucid.button_ [Lucid.class_ "sort-dropdown-btn navbar-dropdown-btn"]
+ <| Lucid.toHtml (sortOrderLabel currentSort <> " ▾")
+ Lucid.div_ [Lucid.class_ "sort-dropdown-content navbar-dropdown-content"] <| do
+ sortOption basePath SortNewest currentSort
+ sortOption basePath SortOldest currentSort
+ sortOption basePath SortUpdated currentSort
+ sortOption basePath SortPriorityHigh currentSort
+ sortOption basePath SortPriorityLow currentSort
+
+sortOption :: (Monad m) => Text -> SortOrder -> SortOrder -> Lucid.HtmlT m ()
+sortOption basePath option currentSort =
+ let cls = "sort-dropdown-item navbar-dropdown-item" <> if option == currentSort then " active" else ""
+ href = basePath <> "?sort=" <> sortOrderToParam option
+ in Lucid.a_ [Lucid.href_ href, Lucid.class_ cls] (Lucid.toHtml (sortOrderLabel option))
+
+-- * Progress bars
+
+multiColorProgressBar :: (Monad m) => TaskCore.TaskStats -> Lucid.HtmlT m ()
+multiColorProgressBar stats =
+ let total = TaskCore.totalTasks stats
+ doneCount = TaskCore.doneTasks stats
+ inProgressCount = TaskCore.inProgressTasks stats
+ openCount = TaskCore.openTasks stats + TaskCore.reviewTasks stats + TaskCore.approvedTasks stats
+ donePct = if total == 0 then 0 else (doneCount * 100) `div` total
+ inProgressPct = if total == 0 then 0 else (inProgressCount * 100) `div` total
+ openPct = if total == 0 then 0 else (openCount * 100) `div` total
+ in Lucid.div_ [Lucid.class_ "multi-progress-container"] <| do
+ Lucid.div_ [Lucid.class_ "multi-progress-bar"] <| do
+ when (donePct > 0)
+ <| Lucid.div_
+ [ Lucid.class_ "multi-progress-segment progress-done",
+ Lucid.style_ ("width: " <> tshow donePct <> "%"),
+ Lucid.title_ (tshow doneCount <> " done")
+ ]
+ ""
+ when (inProgressPct > 0)
+ <| Lucid.div_
+ [ Lucid.class_ "multi-progress-segment progress-inprogress",
+ Lucid.style_ ("width: " <> tshow inProgressPct <> "%"),
+ Lucid.title_ (tshow inProgressCount <> " in progress")
+ ]
+ ""
+ when (openPct > 0)
+ <| Lucid.div_
+ [ Lucid.class_ "multi-progress-segment progress-open",
+ Lucid.style_ ("width: " <> tshow openPct <> "%"),
+ Lucid.title_ (tshow openCount <> " open")
+ ]
+ ""
+ Lucid.div_ [Lucid.class_ "progress-legend"] <| do
+ Lucid.span_ [Lucid.class_ "legend-item"] <| do
+ Lucid.span_ [Lucid.class_ "legend-dot legend-done"] ""
+ Lucid.toHtml ("Done " <> tshow doneCount)
+ Lucid.span_ [Lucid.class_ "legend-item"] <| do
+ Lucid.span_ [Lucid.class_ "legend-dot legend-inprogress"] ""
+ Lucid.toHtml ("In Progress " <> tshow inProgressCount)
+ Lucid.span_ [Lucid.class_ "legend-item"] <| do
+ Lucid.span_ [Lucid.class_ "legend-dot legend-open"] ""
+ Lucid.toHtml ("Open " <> tshow openCount)
+
+epicProgressBar :: (Monad m) => Int -> Int -> Int -> Int -> Lucid.HtmlT m ()
+epicProgressBar doneCount inProgressCount openCount totalCount =
+ let donePct = if totalCount == 0 then 0 else (doneCount * 100) `div` totalCount
+ inProgressPct = if totalCount == 0 then 0 else (inProgressCount * 100) `div` totalCount
+ openPct = if totalCount == 0 then 0 else (openCount * 100) `div` totalCount
+ in Lucid.div_ [Lucid.class_ "multi-progress-container epic-progress"] <| do
+ Lucid.div_ [Lucid.class_ "multi-progress-bar"] <| do
+ when (donePct > 0)
+ <| Lucid.div_
+ [ Lucid.class_ "multi-progress-segment progress-done",
+ Lucid.style_ ("width: " <> tshow donePct <> "%"),
+ Lucid.title_ (tshow doneCount <> " done")
+ ]
+ ""
+ when (inProgressPct > 0)
+ <| Lucid.div_
+ [ Lucid.class_ "multi-progress-segment progress-inprogress",
+ Lucid.style_ ("width: " <> tshow inProgressPct <> "%"),
+ Lucid.title_ (tshow inProgressCount <> " in progress")
+ ]
+ ""
+ when (openPct > 0)
+ <| Lucid.div_
+ [ Lucid.class_ "multi-progress-segment progress-open",
+ Lucid.style_ ("width: " <> tshow openPct <> "%"),
+ Lucid.title_ (tshow openCount <> " open")
+ ]
+ ""
+ Lucid.div_ [Lucid.class_ "progress-legend"] <| do
+ Lucid.span_ [Lucid.class_ "legend-item"] <| do
+ Lucid.span_ [Lucid.class_ "legend-dot legend-done"] ""
+ Lucid.toHtml (tshow doneCount)
+ Lucid.span_ [Lucid.class_ "legend-item"] <| do
+ Lucid.span_ [Lucid.class_ "legend-dot legend-inprogress"] ""
+ Lucid.toHtml (tshow inProgressCount)
+ Lucid.span_ [Lucid.class_ "legend-item"] <| do
+ Lucid.span_ [Lucid.class_ "legend-dot legend-open"] ""
+ Lucid.toHtml (tshow openCount)
+
+-- * Status badge with form
+
+statusBadgeWithForm :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
+statusBadgeWithForm status tid =
+ Lucid.div_
+ [ Lucid.id_ "status-badge-container",
+ Lucid.class_ "status-badge-dropdown"
+ ]
+ <| do
+ clickableBadge status tid
+ statusDropdownOptions status tid
+
+clickableBadge :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
+clickableBadge status _tid =
+ let (cls, label) = case status of
+ TaskCore.Draft -> ("badge badge-draft status-badge-clickable", "Draft" :: Text)
+ TaskCore.Open -> ("badge badge-open status-badge-clickable", "Open")
+ TaskCore.InProgress -> ("badge badge-inprogress status-badge-clickable", "In Progress")
+ TaskCore.Review -> ("badge badge-review status-badge-clickable", "Review")
+ TaskCore.Approved -> ("badge badge-approved status-badge-clickable", "Approved")
+ TaskCore.Done -> ("badge badge-done status-badge-clickable", "Done")
+ TaskCore.NeedsHelp -> ("badge badge-needshelp status-badge-clickable", "Needs Help")
+ in Lucid.span_
+ [ Lucid.class_ cls,
+ Lucid.tabindex_ "0",
+ Lucid.role_ "button",
+ Lucid.makeAttribute "aria-haspopup" "true",
+ Lucid.makeAttribute "aria-expanded" "false",
+ Lucid.makeAttribute "onclick" "toggleStatusDropdown(this)",
+ Lucid.makeAttribute "onkeydown" "handleStatusKeydown(event, this)"
+ ]
+ <| do
+ Lucid.toHtml label
+ Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾"
+
+statusDropdownOptions :: (Monad m) => TaskCore.Status -> Text -> Lucid.HtmlT m ()
+statusDropdownOptions currentStatus tid =
+ Lucid.div_
+ [ Lucid.class_ "status-dropdown-menu",
+ Lucid.role_ "menu",
+ Lucid.makeAttribute "aria-label" "Change task status"
+ ]
+ <| do
+ statusOption TaskCore.Draft currentStatus tid
+ statusOption TaskCore.Open currentStatus tid
+ statusOption TaskCore.InProgress currentStatus tid
+ statusOption TaskCore.Review currentStatus tid
+ statusOption TaskCore.Approved currentStatus tid
+ statusOption TaskCore.Done currentStatus tid
+ statusOption TaskCore.NeedsHelp currentStatus tid
+
+statusOption :: (Monad m) => TaskCore.Status -> TaskCore.Status -> Text -> Lucid.HtmlT m ()
+statusOption opt currentStatus tid =
+ let (cls, label) = case opt of
+ TaskCore.Draft -> ("badge badge-draft", "Draft" :: Text)
+ TaskCore.Open -> ("badge badge-open", "Open")
+ TaskCore.InProgress -> ("badge badge-inprogress", "In Progress")
+ TaskCore.Review -> ("badge badge-review", "Review")
+ TaskCore.Approved -> ("badge badge-approved", "Approved")
+ TaskCore.Done -> ("badge badge-done", "Done")
+ TaskCore.NeedsHelp -> ("badge badge-needshelp", "Needs Help")
+ isSelected = opt == currentStatus
+ optClass = cls <> " status-dropdown-option" <> if isSelected then " selected" else ""
+ in Lucid.form_
+ [ Lucid.class_ "status-option-form",
+ Lucid.role_ "none",
+ Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/status"),
+ Lucid.makeAttribute "hx-target" "#status-badge-container",
+ Lucid.makeAttribute "hx-swap" "outerHTML"
+ ]
+ <| do
+ Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "status", Lucid.value_ (tshow opt)]
+ Lucid.button_
+ [ Lucid.type_ "submit",
+ Lucid.class_ optClass,
+ Lucid.role_ "menuitem",
+ Lucid.tabindex_ "-1",
+ Lucid.makeAttribute "onkeydown" "handleMenuItemKeydown(event)"
+ ]
+ (Lucid.toHtml label)
+
+-- * Priority badge with form
+
+priorityBadgeWithForm :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m ()
+priorityBadgeWithForm priority tid =
+ Lucid.div_
+ [ Lucid.id_ "priority-badge-container",
+ Lucid.class_ "priority-badge-dropdown"
+ ]
+ <| do
+ clickablePriorityBadge priority tid
+ priorityDropdownOptions priority tid
+
+clickablePriorityBadge :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m ()
+clickablePriorityBadge priority _tid =
+ let (cls, label) = case priority of
+ TaskCore.P0 -> ("badge badge-p0 priority-badge-clickable", "P0 Critical" :: Text)
+ TaskCore.P1 -> ("badge badge-p1 priority-badge-clickable", "P1 High")
+ TaskCore.P2 -> ("badge badge-p2 priority-badge-clickable", "P2 Normal")
+ TaskCore.P3 -> ("badge badge-p3 priority-badge-clickable", "P3 Low")
+ TaskCore.P4 -> ("badge badge-p4 priority-badge-clickable", "P4 Defer")
+ in Lucid.span_
+ [ Lucid.class_ cls,
+ Lucid.tabindex_ "0",
+ Lucid.role_ "button",
+ Lucid.makeAttribute "aria-haspopup" "true",
+ Lucid.makeAttribute "aria-expanded" "false",
+ Lucid.makeAttribute "onclick" "togglePriorityDropdown(this)",
+ Lucid.makeAttribute "onkeydown" "handlePriorityKeydown(event, this)"
+ ]
+ <| do
+ Lucid.toHtml label
+ Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾"
+
+priorityDropdownOptions :: (Monad m) => TaskCore.Priority -> Text -> Lucid.HtmlT m ()
+priorityDropdownOptions currentPriority tid =
+ Lucid.div_
+ [ Lucid.class_ "priority-dropdown-menu",
+ Lucid.role_ "menu",
+ Lucid.makeAttribute "aria-label" "Change task priority"
+ ]
+ <| do
+ priorityOption TaskCore.P0 currentPriority tid
+ priorityOption TaskCore.P1 currentPriority tid
+ priorityOption TaskCore.P2 currentPriority tid
+ priorityOption TaskCore.P3 currentPriority tid
+ priorityOption TaskCore.P4 currentPriority tid
+
+priorityOption :: (Monad m) => TaskCore.Priority -> TaskCore.Priority -> Text -> Lucid.HtmlT m ()
+priorityOption opt currentPriority tid =
+ let (cls, label) = case opt of
+ TaskCore.P0 -> ("badge badge-p0", "P0 Critical" :: Text)
+ TaskCore.P1 -> ("badge badge-p1", "P1 High")
+ TaskCore.P2 -> ("badge badge-p2", "P2 Normal")
+ TaskCore.P3 -> ("badge badge-p3", "P3 Low")
+ TaskCore.P4 -> ("badge badge-p4", "P4 Defer")
+ isSelected = opt == currentPriority
+ optClass = cls <> " priority-dropdown-option" <> if isSelected then " selected" else ""
+ in Lucid.form_
+ [ Lucid.class_ "priority-option-form",
+ Lucid.role_ "none",
+ Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/priority"),
+ Lucid.makeAttribute "hx-target" "#priority-badge-container",
+ Lucid.makeAttribute "hx-swap" "outerHTML"
+ ]
+ <| do
+ Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "priority", Lucid.value_ (tshow opt)]
+ Lucid.button_
+ [ Lucid.type_ "submit",
+ Lucid.class_ optClass,
+ Lucid.role_ "menuitem",
+ Lucid.tabindex_ "-1",
+ Lucid.makeAttribute "onkeydown" "handlePriorityMenuItemKeydown(event)"
+ ]
+ (Lucid.toHtml label)
+
+-- * Task rendering
+
+renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
+renderTaskCard t =
+ Lucid.a_
+ [ Lucid.class_ "task-card task-card-link",
+ Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "task-header"] <| do
+ Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (TaskCore.taskId t))
+ statusBadge (TaskCore.taskStatus t)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
+ Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
+
+renderBlockedTaskCard :: (Monad m) => (TaskCore.Task, Int) -> Lucid.HtmlT m ()
+renderBlockedTaskCard (t, impact) =
+ Lucid.a_
+ [ Lucid.class_ "task-card task-card-link",
+ Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "task-header"] <| do
+ Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (TaskCore.taskId t))
+ statusBadge (TaskCore.taskStatus t)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
+ when (impact > 0)
+ <| Lucid.span_ [Lucid.class_ "blocking-impact"] (Lucid.toHtml ("Blocks " <> tshow impact))
+ Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
+
+renderListGroupItem :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
+renderListGroupItem t =
+ Lucid.a_
+ [ Lucid.class_ "list-group-item",
+ Lucid.href_ ("/tasks/" <> TaskCore.taskId t),
+ Lucid.makeAttribute "hx-boost" "true",
+ Lucid.makeAttribute "hx-target" "body",
+ Lucid.makeAttribute "hx-swap" "innerHTML"
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "list-group-item-content"] <| do
+ Lucid.span_ [Lucid.class_ "list-group-item-id"] (Lucid.toHtml (TaskCore.taskId t))
+ Lucid.span_ [Lucid.class_ "list-group-item-title"] (Lucid.toHtml (TaskCore.taskTitle t))
+ Lucid.div_ [Lucid.class_ "list-group-item-meta"] <| do
+ statusBadge (TaskCore.taskStatus t)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
+
+renderEpicReviewCard :: (Monad m) => TaskCore.EpicForReview -> Lucid.HtmlT m ()
+renderEpicReviewCard epicReview = do
+ let task = TaskCore.epicTask epicReview
+ total = TaskCore.epicTotal epicReview
+ completed = TaskCore.epicCompleted epicReview
+ progressText = tshow completed <> "/" <> tshow total <> " subtasks done"
+ Lucid.div_ [Lucid.class_ "task-card"] <| do
+ Lucid.div_ [Lucid.class_ "task-card-header"] <| do
+ Lucid.div_ [Lucid.class_ "task-title-row"] <| do
+ Lucid.a_
+ [Lucid.href_ ("/tasks/" <> TaskCore.taskId task), Lucid.class_ "task-link"]
+ <| Lucid.toHtml (TaskCore.taskTitle task)
+ Lucid.span_ [Lucid.class_ "badge badge-epic"] "Epic"
+ Lucid.span_ [Lucid.class_ "task-id"] <| Lucid.toHtml (TaskCore.taskId task)
+ Lucid.div_ [Lucid.class_ "task-card-body"] <| do
+ Lucid.div_ [Lucid.class_ "progress-info"] <| do
+ Lucid.span_ [Lucid.class_ "badge badge-success"] <| Lucid.toHtml progressText
+ Lucid.div_ [Lucid.class_ "epic-actions"] <| do
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/status"),
+ Lucid.class_ "inline-form"
+ ]
+ <| do
+ Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "status", Lucid.value_ "done"]
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-success btn-sm"] "Approve & Close"
+
+renderEpicCardWithStats :: (Monad m) => [TaskCore.Task] -> TaskCore.Task -> Lucid.HtmlT m ()
+renderEpicCardWithStats allTasks t =
+ let children = getDescendants allTasks (TaskCore.taskId t)
+ openCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.Open]
+ inProgressCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.InProgress]
+ reviewCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.Review]
+ doneCount = length [c | c <- children, TaskCore.taskStatus c == TaskCore.Done]
+ totalCount = length children
+ openAndReview = openCount + reviewCount
+ in Lucid.a_
+ [ Lucid.class_ "task-card task-card-link",
+ Lucid.href_ ("/tasks/" <> TaskCore.taskId t)
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "task-header"] <| do
+ Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (TaskCore.taskId t))
+ statusBadge (TaskCore.taskStatus t)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (tshow (TaskCore.taskPriority t)))
+ Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (TaskCore.taskTitle t))
+ when (totalCount > 0) <| epicProgressBar doneCount inProgressCount openAndReview totalCount
+ unless (Text.null (TaskCore.taskDescription t))
+ <| Lucid.p_ [Lucid.class_ "kb-preview"] (Lucid.toHtml (Text.take 200 (TaskCore.taskDescription t) <> "..."))
+
+getDescendants :: [TaskCore.Task] -> Text -> [TaskCore.Task]
+getDescendants allTasks parentId =
+ let children = [c | c <- allTasks, maybe False (TaskCore.matchesId parentId) (TaskCore.taskParent c)]
+ in children ++ concatMap (getDescendants allTasks <. TaskCore.taskId) children
+
+-- * Aggregated metrics
+
+renderAggregatedMetrics :: (Monad m) => [TaskCore.Task] -> TaskCore.Task -> TaskCore.AggregatedMetrics -> Lucid.HtmlT m ()
+renderAggregatedMetrics allTasks task metrics =
+ let descendants = getDescendants allTasks (TaskCore.taskId task)
+ totalCount = length descendants
+ costCents = TaskCore.aggTotalCostCents metrics
+ durationSecs = TaskCore.aggTotalDurationSeconds metrics
+ completedCount = TaskCore.aggCompletedTasks metrics
+ tokensUsed = TaskCore.aggTotalTokens metrics
+ in Lucid.div_ [Lucid.class_ "detail-section aggregated-metrics"] <| do
+ Lucid.h3_ "Execution Summary"
+ Lucid.div_ [Lucid.class_ "metrics-grid"] <| do
+ Lucid.div_ [Lucid.class_ "metric-card"] <| do
+ Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (tshow completedCount <> "/" <> tshow totalCount))
+ Lucid.div_ [Lucid.class_ "metric-label"] "Tasks Completed"
+ Lucid.div_ [Lucid.class_ "metric-card"] <| do
+ Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCostMetric costCents))
+ Lucid.div_ [Lucid.class_ "metric-label"] "Total Cost"
+ Lucid.div_ [Lucid.class_ "metric-card"] <| do
+ Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDurationMetric durationSecs))
+ Lucid.div_ [Lucid.class_ "metric-label"] "Total Time"
+ when (tokensUsed > 0) <| do
+ Lucid.div_ [Lucid.class_ "metric-card"] <| do
+ Lucid.div_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTokensMetric tokensUsed))
+ Lucid.div_ [Lucid.class_ "metric-label"] "Tokens Used"
+ where
+ formatCostMetric :: Int -> Text
+ formatCostMetric cents =
+ let dollars = fromIntegral cents / 100.0 :: Double
+ in "$" <> Text.pack (showFFloat (Just 2) dollars "")
+
+ formatDurationMetric :: Int -> Text
+ formatDurationMetric secs
+ | secs < 60 = tshow secs <> "s"
+ | secs < 3600 =
+ let mins = secs `div` 60
+ remSecs = secs `mod` 60
+ in tshow mins <> "m " <> tshow remSecs <> "s"
+ | otherwise =
+ let hrs = secs `div` 3600
+ mins = (secs `mod` 3600) `div` 60
+ in tshow hrs <> "h " <> tshow mins <> "m"
+
+ formatTokensMetric :: Int -> Text
+ formatTokensMetric t
+ | t < 1000 = tshow t
+ | t < 1000000 = Text.pack (showFFloat (Just 1) (fromIntegral t / 1000.0 :: Double) "") <> "K"
+ | otherwise = Text.pack (showFFloat (Just 2) (fromIntegral t / 1000000.0 :: Double) "") <> "M"
+
+-- * Retry context banner
+
+renderRetryContextBanner :: (Monad m) => Text -> Maybe TaskCore.RetryContext -> Lucid.HtmlT m ()
+renderRetryContextBanner _ Nothing = pure ()
+renderRetryContextBanner tid (Just ctx) =
+ Lucid.div_ [Lucid.class_ bannerClass] <| do
+ Lucid.div_ [Lucid.class_ "retry-banner-header"] <| do
+ Lucid.span_ [Lucid.class_ "retry-icon"] retryIcon
+ Lucid.span_ [Lucid.class_ "retry-attempt"] (Lucid.toHtml attemptText)
+ when maxRetriesExceeded
+ <| Lucid.span_ [Lucid.class_ "retry-warning-badge"] "Needs Human Intervention"
+
+ Lucid.div_ [Lucid.class_ "retry-banner-details"] <| do
+ Lucid.div_ [Lucid.class_ "retry-detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "retry-label"] "Failure Reason:"
+ Lucid.span_ [Lucid.class_ "retry-value"] (Lucid.toHtml (summarizeReason (TaskCore.retryReason ctx)))
+
+ let commit = TaskCore.retryOriginalCommit ctx
+ unless (Text.null commit) <| do
+ Lucid.div_ [Lucid.class_ "retry-detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "retry-label"] "Original Commit:"
+ Lucid.code_ [Lucid.class_ "retry-commit"] (Lucid.toHtml (Text.take 8 commit))
+
+ let conflicts = TaskCore.retryConflictFiles ctx
+ unless (null conflicts) <| do
+ Lucid.div_ [Lucid.class_ "retry-detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "retry-label"] "Conflict Files:"
+ Lucid.ul_ [Lucid.class_ "retry-conflict-list"]
+ <| traverse_ (Lucid.li_ <. Lucid.toHtml) conflicts
+
+ when maxRetriesExceeded <| do
+ Lucid.div_
+ [Lucid.class_ "retry-warning-message"]
+ "This task has exceeded the maximum number of retries. A human must review the failure and either fix the issue manually or reset the retry count."
+
+ Lucid.p_ [Lucid.class_ "retry-hint"] "Use comments below to provide guidance for retry."
+
+ Lucid.div_ [Lucid.class_ "retry-reset-section"] <| do
+ Lucid.h4_ "Reset Retries"
+ Lucid.p_ [Lucid.class_ "notes-help"] "Clear retry context and give task a fresh start:"
+ Lucid.form_ [Lucid.method_ "POST", Lucid.action_ ("/tasks/" <> tid <> "/reset-retries")] <| do
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "reset-btn"] "Reset Retries"
+ where
+ attempt = TaskCore.retryAttempt ctx
+ maxRetriesExceeded = attempt >= 3
+ bannerClass = if maxRetriesExceeded then "retry-banner retry-banner-critical" else "retry-banner retry-banner-warning"
+ retryIcon = if maxRetriesExceeded then "⚠" else "↻"
+ attemptText = "Attempt " <> tshow attempt <> " of 3"
+
+ summarizeReason :: Text -> Text
+ summarizeReason reason
+ | "rejected:" `Text.isPrefixOf` reason = "Rejected: " <> Text.strip (Text.drop 9 reason)
+ | "Test failure:" `Text.isPrefixOf` reason = "Test failure (see details below)"
+ | "MERGE CONFLICT" `Text.isPrefixOf` reason = "Merge conflict with concurrent changes"
+ | otherwise = Text.take 100 reason <> if Text.length reason > 100 then "..." else ""
+
+-- * Markdown rendering
+
+renderMarkdown :: (Monad m) => Text -> Lucid.HtmlT m ()
+renderMarkdown input = renderBlocks (parseBlocks (Text.lines input))
+
+data MarkdownBlock
+ = MdHeader Int Text
+ | MdParagraph [Text]
+ | MdCodeBlock [Text]
+ | MdList [Text]
+ deriving (Show, Eq)
+
+parseBlocks :: [Text] -> [MarkdownBlock]
+parseBlocks [] = []
+parseBlocks lns = case lns of
+ (l : rest)
+ | "```" `Text.isPrefixOf` l ->
+ let (codeLines, afterCode) = List.span (not <. Text.isPrefixOf "```") rest
+ remaining = List.drop 1 afterCode
+ in MdCodeBlock codeLines : parseBlocks remaining
+ | "### " `Text.isPrefixOf` l ->
+ MdHeader 3 (Text.drop 4 l) : parseBlocks rest
+ | "## " `Text.isPrefixOf` l ->
+ MdHeader 2 (Text.drop 3 l) : parseBlocks rest
+ | "# " `Text.isPrefixOf` l ->
+ MdHeader 1 (Text.drop 2 l) : parseBlocks rest
+ | isListItem l ->
+ let (listLines, afterList) = List.span isListItem lns
+ in MdList (map stripListPrefix listLines) : parseBlocks afterList
+ | Text.null (Text.strip l) ->
+ parseBlocks rest
+ | otherwise ->
+ let (paraLines, afterPara) = List.span isParagraphLine lns
+ in MdParagraph paraLines : parseBlocks afterPara
+ where
+ isListItem t =
+ let stripped = Text.stripStart t
+ in "- " `Text.isPrefixOf` stripped || "* " `Text.isPrefixOf` stripped
+ stripListPrefix t =
+ let stripped = Text.stripStart t
+ in Text.drop 2 stripped
+ isParagraphLine t =
+ not (Text.null (Text.strip t))
+ && not ("```" `Text.isPrefixOf` t)
+ && not ("#" `Text.isPrefixOf` t)
+ && not (isListItem t)
+
+renderBlocks :: (Monad m) => [MarkdownBlock] -> Lucid.HtmlT m ()
+renderBlocks = traverse_ renderBlock
+
+renderBlock :: (Monad m) => MarkdownBlock -> Lucid.HtmlT m ()
+renderBlock block = case block of
+ MdHeader 1 txt -> Lucid.h2_ [Lucid.class_ "md-h1"] (renderInline txt)
+ MdHeader 2 txt -> Lucid.h3_ [Lucid.class_ "md-h2"] (renderInline txt)
+ MdHeader 3 txt -> Lucid.h4_ [Lucid.class_ "md-h3"] (renderInline txt)
+ MdHeader _ txt -> Lucid.h4_ (renderInline txt)
+ MdParagraph lns -> Lucid.p_ [Lucid.class_ "md-para"] (renderInline (Text.unlines lns))
+ MdCodeBlock lns -> Lucid.pre_ [Lucid.class_ "md-code"] (Lucid.code_ (Lucid.toHtml (Text.unlines lns)))
+ MdList items -> Lucid.ul_ [Lucid.class_ "md-list"] (traverse_ renderListItem items)
+
+renderListItem :: (Monad m) => Text -> Lucid.HtmlT m ()
+renderListItem txt = Lucid.li_ (renderInline txt)
+
+renderInline :: (Monad m) => Text -> Lucid.HtmlT m ()
+renderInline txt = renderInlineParts (parseInline txt)
+
+data InlinePart = PlainText Text | InlineCode Text | BoldText Text
+ deriving (Show, Eq)
+
+parseInline :: Text -> [InlinePart]
+parseInline t
+ | Text.null t = []
+ | otherwise = case Text.breakOn "`" t of
+ (before, rest)
+ | Text.null rest -> parseBold before
+ | otherwise ->
+ let afterTick = Text.drop 1 rest
+ in case Text.breakOn "`" afterTick of
+ (code, rest2)
+ | Text.null rest2 ->
+ parseBold before ++ [PlainText ("`" <> afterTick)]
+ | otherwise ->
+ parseBold before ++ [InlineCode code] ++ parseInline (Text.drop 1 rest2)
+
+parseBold :: Text -> [InlinePart]
+parseBold t
+ | Text.null t = []
+ | otherwise = case Text.breakOn "**" t of
+ (before, rest)
+ | Text.null rest -> [PlainText before | not (Text.null before)]
+ | otherwise ->
+ let afterBold = Text.drop 2 rest
+ in case Text.breakOn "**" afterBold of
+ (boldText, rest2)
+ | Text.null rest2 ->
+ [PlainText before | not (Text.null before)] ++ [PlainText ("**" <> afterBold)]
+ | otherwise ->
+ [PlainText before | not (Text.null before)]
+ ++ [BoldText boldText]
+ ++ parseBold (Text.drop 2 rest2)
+
+renderInlineParts :: (Monad m) => [InlinePart] -> Lucid.HtmlT m ()
+renderInlineParts = traverse_ renderInlinePart
+
+renderInlinePart :: (Monad m) => InlinePart -> Lucid.HtmlT m ()
+renderInlinePart part = case part of
+ PlainText txt -> Lucid.toHtml txt
+ InlineCode txt -> Lucid.code_ [Lucid.class_ "md-inline-code"] (Lucid.toHtml txt)
+ BoldText txt -> Lucid.strong_ (Lucid.toHtml txt)
+
+-- * Comment form
+
+commentForm :: (Monad m) => Text -> Lucid.HtmlT m ()
+commentForm tid =
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ ("/tasks/" <> tid <> "/comment"),
+ Lucid.class_ "comment-form"
+ ]
+ <| do
+ Lucid.textarea_
+ [ Lucid.name_ "comment",
+ Lucid.placeholder_ "Add a comment...",
+ Lucid.rows_ "3",
+ Lucid.class_ "comment-textarea"
+ ]
+ ""
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Post Comment"
+
+-- * Live toggles
+
+renderLiveToggle :: (Monad m) => Lucid.HtmlT m ()
+renderLiveToggle =
+ Lucid.button_
+ [ Lucid.class_ "timeline-live-toggle",
+ Lucid.id_ "live-toggle",
+ Lucid.makeAttribute "onclick" "toggleLiveUpdates()",
+ Lucid.title_ "Click to pause/resume live updates"
+ ]
+ " LIVE"
+
+renderAutoscrollToggle :: (Monad m) => Lucid.HtmlT m ()
+renderAutoscrollToggle =
+ Lucid.button_
+ [ Lucid.class_ "timeline-autoscroll-toggle",
+ Lucid.id_ "autoscroll-toggle",
+ Lucid.makeAttribute "onclick" "toggleAutoscroll()",
+ Lucid.title_ "Toggle automatic scrolling to newest events"
+ ]
+ " ⬇ Auto-scroll"
+
+-- * Cost/Token metrics
+
+aggregateCostMetrics :: [TaskCore.StoredEvent] -> (Int, Int)
+aggregateCostMetrics events =
+ let costEvents = filter (\e -> TaskCore.storedEventType e == "Cost") events
+ aggregateOne (totalCents, totalTokens) event =
+ case Aeson.decode (LBS.fromStrict (str (TaskCore.storedEventContent event))) of
+ Just (Aeson.Object obj) ->
+ let cents = case KeyMap.lookup "cents" obj of
+ Just (Aeson.Number n) -> floor n
+ _ -> 0
+ tokens = case KeyMap.lookup "tokens" obj of
+ Just (Aeson.Number n) -> floor n
+ _ -> 0
+ in (totalCents + cents, totalTokens + tokens)
+ _ -> (totalCents, totalTokens)
+ in foldl' aggregateOne (0, 0) costEvents
+
+formatCostHeader :: Int -> Text
+formatCostHeader cents =
+ let dollars = fromIntegral cents / 100.0 :: Double
+ in "$" <> Text.pack (showFFloat (Just 2) dollars "")
+
+formatTokensHeader :: Int -> Text
+formatTokensHeader t
+ | t < 1000 = tshow t
+ | t < 1000000 = Text.pack (showFFloat (Just 1) (fromIntegral t / 1000.0 :: Double) "") <> "K"
+ | otherwise = Text.pack (showFFloat (Just 2) (fromIntegral t / 1000000.0 :: Double) "") <> "M"
+
+-- * Timeline
+
+renderUnifiedTimeline :: (Monad m) => Text -> [TaskCore.Comment] -> [TaskCore.StoredEvent] -> TaskCore.Status -> UTCTime -> Lucid.HtmlT m ()
+renderUnifiedTimeline tid legacyComments events status now = do
+ let isInProgress = status == TaskCore.InProgress
+ pollAttrs =
+ if isInProgress
+ then
+ [ Lucid.makeAttribute "hx-get" ("/partials/task/" <> tid <> "/events"),
+ Lucid.makeAttribute "hx-trigger" "every 3s",
+ Lucid.makeAttribute "hx-swap" "innerHTML",
+ Lucid.makeAttribute "hx-on::before-request" "var log = this.querySelector('.timeline-events'); if(log) this.dataset.scroll = log.scrollTop",
+ Lucid.makeAttribute "hx-on::after-swap" "var log = this.querySelector('.timeline-events'); if(log && this.dataset.scroll) log.scrollTop = this.dataset.scroll"
+ ]
+ else []
+ nonCostEvents = filter (\e -> TaskCore.storedEventType e /= "Cost") events
+ eventCount = length nonCostEvents + length legacyComments
+ (totalCents, totalTokens) = aggregateCostMetrics events
+ Lucid.div_ ([Lucid.class_ "unified-timeline-section", Lucid.id_ "unified-timeline"] <> pollAttrs) <| do
+ Lucid.h3_ <| do
+ Lucid.toHtml ("Timeline (" <> tshow eventCount <> ")")
+ when (totalCents > 0 || totalTokens > 0) <| do
+ Lucid.span_ [Lucid.class_ "timeline-cost-summary"] <| do
+ metaSep
+ when (totalCents > 0) <| Lucid.toHtml (formatCostHeader totalCents)
+ when (totalCents > 0 && totalTokens > 0) <| metaSep
+ when (totalTokens > 0) <| Lucid.toHtml (formatTokensHeader totalTokens <> " tokens")
+ when isInProgress <| do
+ renderLiveToggle
+ renderAutoscrollToggle
+
+ if null nonCostEvents && null legacyComments
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No activity yet."
+ else do
+ Lucid.div_ [Lucid.class_ "timeline-events"] <| do
+ traverse_ (renderTimelineEvent now) nonCostEvents
+ when isInProgress <| timelineScrollScript
+
+ commentForm tid
+
+renderTimelineEvent :: (Monad m) => UTCTime -> TaskCore.StoredEvent -> Lucid.HtmlT m ()
+renderTimelineEvent now event =
+ let eventType = TaskCore.storedEventType event
+ content = TaskCore.storedEventContent event
+ timestamp = TaskCore.storedEventTimestamp event
+ actor = TaskCore.storedEventActor event
+ eventId = TaskCore.storedEventId event
+ (icon, label) = eventTypeIconAndLabel eventType
+ in Lucid.div_
+ [ Lucid.class_ ("timeline-event timeline-event-" <> eventType),
+ Lucid.makeAttribute "data-event-id" (tshow eventId)
+ ]
+ <| do
+ case eventType of
+ "comment" -> renderCommentTimelineEvent content actor timestamp now
+ "status_change" -> renderStatusChangeEvent content actor timestamp now
+ "claim" -> renderActivityEvent icon label content actor timestamp now
+ "running" -> renderActivityEvent icon label content actor timestamp now
+ "reviewing" -> renderActivityEvent icon label content actor timestamp now
+ "retrying" -> renderActivityEvent icon label content actor timestamp now
+ "complete" -> renderActivityEvent icon label content actor timestamp now
+ "error" -> renderErrorTimelineEvent content actor timestamp now
+ "Assistant" -> renderAssistantTimelineEvent content actor timestamp now
+ "ToolCall" -> renderToolCallTimelineEvent content actor timestamp now
+ "ToolResult" -> renderToolResultTimelineEvent content actor timestamp now
+ "Cost" -> pure ()
+ "Checkpoint" -> renderCheckpointEvent content actor timestamp now
+ "Guardrail" -> renderGuardrailEvent content actor timestamp now
+ _ -> renderGenericEvent eventType content actor timestamp now
+
+eventTypeIconAndLabel :: Text -> (Text, Text)
+eventTypeIconAndLabel "comment" = ("💬", "Comment")
+eventTypeIconAndLabel "status_change" = ("🔄", "Status")
+eventTypeIconAndLabel "claim" = ("🤖", "Claimed")
+eventTypeIconAndLabel "running" = ("▶️", "Running")
+eventTypeIconAndLabel "reviewing" = ("👀", "Reviewing")
+eventTypeIconAndLabel "retrying" = ("🔁", "Retrying")
+eventTypeIconAndLabel "complete" = ("✅", "Complete")
+eventTypeIconAndLabel "error" = ("❌", "Error")
+eventTypeIconAndLabel "Assistant" = ("💭", "Thought")
+eventTypeIconAndLabel "ToolCall" = ("🔧", "Tool")
+eventTypeIconAndLabel "ToolResult" = ("📄", "Result")
+eventTypeIconAndLabel "Cost" = ("💰", "Cost")
+eventTypeIconAndLabel "Checkpoint" = ("📍", "Checkpoint")
+eventTypeIconAndLabel "Guardrail" = ("⚠️", "Guardrail")
+eventTypeIconAndLabel t = ("📝", t)
+
+renderActorLabel :: (Monad m) => TaskCore.CommentAuthor -> Lucid.HtmlT m ()
+renderActorLabel actor =
+ let (cls, label) :: (Text, Text) = case actor of
+ TaskCore.Human -> ("actor-human", "human")
+ TaskCore.Junior -> ("actor-junior", "junior")
+ TaskCore.System -> ("actor-system", "system")
+ in Lucid.span_ [Lucid.class_ ("actor-label " <> cls)] (Lucid.toHtml ("[" <> label <> "]"))
+
+renderCommentTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderCommentTimelineEvent content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-comment"] <| do
+ Lucid.div_ [Lucid.class_ "event-header"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "💬"
+ renderActorLabel actor
+ renderRelativeTimestamp now timestamp
+ Lucid.div_ [Lucid.class_ "event-content comment-bubble markdown-content"] <| do
+ renderMarkdown content
+
+renderStatusChangeEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderStatusChangeEvent content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-status-change"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "🔄"
+ renderActorLabel actor
+ Lucid.span_ [Lucid.class_ "status-change-text"] (Lucid.toHtml (parseStatusChange content))
+ renderRelativeTimestamp now timestamp
+
+parseStatusChange :: Text -> Text
+parseStatusChange content =
+ case Aeson.decode (LBS.fromStrict (str content)) of
+ Just (Aeson.Object obj) ->
+ let fromStatus = case KeyMap.lookup "from" obj of
+ Just (Aeson.String s) -> s
+ _ -> "?"
+ toStatus = case KeyMap.lookup "to" obj of
+ Just (Aeson.String s) -> s
+ _ -> "?"
+ in fromStatus <> " → " <> toStatus
+ _ -> content
+
+renderActivityEvent :: (Monad m) => Text -> Text -> Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderActivityEvent icon label content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-activity"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] (Lucid.toHtml icon)
+ Lucid.span_ [Lucid.class_ "event-label"] (Lucid.toHtml label)
+ renderActorLabel actor
+ unless (Text.null content) <| Lucid.span_ [Lucid.class_ "activity-detail"] (Lucid.toHtml content)
+ renderRelativeTimestamp now timestamp
+
+renderErrorTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderErrorTimelineEvent content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-error"] <| do
+ Lucid.div_ [Lucid.class_ "event-header"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "❌"
+ Lucid.span_ [Lucid.class_ "event-label"] "Error"
+ renderActorLabel actor
+ renderRelativeTimestamp now timestamp
+ Lucid.div_ [Lucid.class_ "event-content error-message"] (Lucid.toHtml content)
+
+renderAssistantTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderAssistantTimelineEvent content _actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-thought"] <| do
+ Lucid.div_ [Lucid.class_ "event-header"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "💭"
+ Lucid.span_ [Lucid.class_ "event-label"] "Thought"
+ renderActorLabel TaskCore.Junior
+ renderRelativeTimestamp now timestamp
+ Lucid.div_ [Lucid.class_ "event-content thought-bubble markdown-content"] <| do
+ let truncated = Text.take 2000 content
+ isTruncated = Text.length content > 2000
+ renderMarkdown truncated
+ when isTruncated <| Lucid.span_ [Lucid.class_ "event-truncated"] "..."
+
+renderToolCallTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderToolCallTimelineEvent content _actor timestamp now =
+ let (toolName, args) = parseToolCallContent content
+ summary = formatToolCallSummary toolName args
+ in Lucid.details_ [Lucid.class_ "timeline-tool-call"] <| do
+ Lucid.summary_ <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "🔧"
+ Lucid.span_ [Lucid.class_ "tool-name"] (Lucid.toHtml toolName)
+ Lucid.span_ [Lucid.class_ "tool-summary"] (Lucid.toHtml summary)
+ renderRelativeTimestamp now timestamp
+ Lucid.div_ [Lucid.class_ "event-content tool-args"] <| do
+ renderCollapsibleOutput args
+
+renderToolResultTimelineEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderToolResultTimelineEvent content _actor timestamp now =
+ let lineCount = length (Text.lines content)
+ in Lucid.details_ [Lucid.class_ "timeline-tool-result"] <| do
+ Lucid.summary_ <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "📄"
+ Lucid.span_ [Lucid.class_ "event-label"] "Result"
+ when (lineCount > 1)
+ <| Lucid.span_ [Lucid.class_ "line-count"] (Lucid.toHtml (tshow lineCount <> " lines"))
+ renderRelativeTimestamp now timestamp
+ Lucid.pre_ [Lucid.class_ "event-content tool-output"] (renderDecodedToolResult content)
+
+renderCheckpointEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderCheckpointEvent content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-checkpoint"] <| do
+ Lucid.div_ [Lucid.class_ "event-header"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "📍"
+ Lucid.span_ [Lucid.class_ "event-label"] "Checkpoint"
+ renderActorLabel actor
+ renderRelativeTimestamp now timestamp
+ Lucid.div_ [Lucid.class_ "event-content checkpoint-content"] (Lucid.toHtml content)
+
+renderGuardrailEvent :: (Monad m) => Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderGuardrailEvent content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-guardrail"] <| do
+ Lucid.div_ [Lucid.class_ "event-header"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "⚠️"
+ Lucid.span_ [Lucid.class_ "event-label"] "Guardrail"
+ renderActorLabel actor
+ renderRelativeTimestamp now timestamp
+ Lucid.div_ [Lucid.class_ "event-content guardrail-content"] (Lucid.toHtml content)
+
+renderGenericEvent :: (Monad m) => Text -> Text -> TaskCore.CommentAuthor -> UTCTime -> UTCTime -> Lucid.HtmlT m ()
+renderGenericEvent eventType content actor timestamp now =
+ Lucid.div_ [Lucid.class_ "timeline-generic"] <| do
+ Lucid.div_ [Lucid.class_ "event-header"] <| do
+ Lucid.span_ [Lucid.class_ "event-icon"] "📝"
+ Lucid.span_ [Lucid.class_ "event-label"] (Lucid.toHtml eventType)
+ renderActorLabel actor
+ renderRelativeTimestamp now timestamp
+ unless (Text.null content) <| Lucid.div_ [Lucid.class_ "event-content"] (Lucid.toHtml content)
+
+parseToolCallContent :: Text -> (Text, Text)
+parseToolCallContent content =
+ case Text.breakOn ":" content of
+ (name, rest)
+ | Text.null rest -> (content, "")
+ | otherwise -> (Text.strip name, Text.strip (Text.drop 1 rest))
+
+formatToolCallSummary :: Text -> Text -> Text
+formatToolCallSummary toolName argsJson =
+ case Aeson.decode (LBS.fromStrict (str argsJson)) of
+ Just (Aeson.Object obj) ->
+ let keyArg = case toolName of
+ "run_bash" -> KeyMap.lookup "command" obj
+ "read_file" -> KeyMap.lookup "path" obj
+ "edit_file" -> KeyMap.lookup "path" obj
+ "write_file" -> KeyMap.lookup "path" obj
+ "search_codebase" -> KeyMap.lookup "pattern" obj
+ "glob_files" -> KeyMap.lookup "pattern" obj
+ "list_directory" -> KeyMap.lookup "path" obj
+ _ -> Nothing
+ in case keyArg of
+ Just (Aeson.String s) -> "`" <> Text.take 100 s <> "`"
+ _ -> Text.take 80 argsJson
+ _ -> Text.take 80 argsJson
+
+renderCollapsibleOutput :: (Monad m) => Text -> Lucid.HtmlT m ()
+renderCollapsibleOutput content =
+ let lineCount = length (Text.lines content)
+ in if lineCount > 20
+ then
+ Lucid.details_ [Lucid.class_ "output-collapsible"] <| do
+ Lucid.summary_ (Lucid.toHtml (tshow lineCount <> " lines"))
+ Lucid.pre_ [Lucid.class_ "tool-output-pre"] (Lucid.toHtml content)
+ else Lucid.pre_ [Lucid.class_ "tool-output-pre"] (Lucid.toHtml content)
+
+renderDecodedToolResult :: (Monad m) => Text -> Lucid.HtmlT m ()
+renderDecodedToolResult content =
+ case Aeson.decode (LBS.fromStrict (str content)) of
+ Just (Aeson.Object obj) ->
+ case KeyMap.lookup "output" obj of
+ Just (Aeson.String output) -> Lucid.toHtml output
+ _ -> Lucid.toHtml content
+ _ -> Lucid.toHtml content
+
+timelineScrollScript :: (Monad m) => Lucid.HtmlT m ()
+timelineScrollScript =
+ Lucid.script_
+ [ Lucid.type_ "text/javascript"
+ ]
+ ( Text.unlines
+ [ "(function() {",
+ " var log = document.querySelector('.timeline-events');",
+ " if (log) {",
+ " var isNearBottom = log.scrollHeight - log.scrollTop - log.clientHeight < 100;",
+ " if (isNearBottom) {",
+ " log.scrollTop = log.scrollHeight;",
+ " }",
+ " }",
+ "})();"
+ ]
+ )
diff --git a/Omni/Jr/Web/Handlers.hs b/Omni/Jr/Web/Handlers.hs
new file mode 100644
index 0000000..463c9f7
--- /dev/null
+++ b/Omni/Jr/Web/Handlers.hs
@@ -0,0 +1,642 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TypeOperators #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- : dep warp
+-- : dep servant-server
+-- : dep lucid
+-- : dep servant-lucid
+-- : dep process
+-- : dep aeson
+module Omni.Jr.Web.Handlers
+ ( server,
+ api,
+ streamAgentEvents,
+ )
+where
+
+import Alpha
+import qualified Control.Concurrent as Concurrent
+import qualified Data.Aeson as Aeson
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.List as List
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LazyText
+import Data.Time (UTCTime, getCurrentTime)
+import Data.Time.Clock.POSIX (posixSecondsToUTCTime, utcTimeToPOSIXSeconds)
+import qualified Omni.Fact as Fact
+import qualified Omni.Jr.Web.Style as Style
+import Omni.Jr.Web.Types
+import qualified Omni.Task.Core as TaskCore
+import Servant
+import qualified Servant.HTML.Lucid as Lucid
+import qualified Servant.Types.SourceT as Source
+import qualified System.Exit as Exit
+import qualified System.Process as Process
+
+type PostRedirect = Verb 'POST 303 '[Lucid.HTML] (Headers '[Header "Location" Text] NoContent)
+
+type API =
+ QueryParam "range" Text :> Get '[Lucid.HTML] HomePage
+ :<|> "style.css" :> Get '[CSS] LazyText.Text
+ :<|> "ready" :> QueryParam "sort" Text :> Get '[Lucid.HTML] ReadyQueuePage
+ :<|> "blocked" :> QueryParam "sort" Text :> Get '[Lucid.HTML] BlockedPage
+ :<|> "intervention" :> QueryParam "sort" Text :> Get '[Lucid.HTML] InterventionPage
+ :<|> "stats" :> QueryParam "epic" Text :> Get '[Lucid.HTML] StatsPage
+ :<|> "tasks"
+ :> QueryParam "status" Text
+ :> QueryParam "priority" Text
+ :> QueryParam "namespace" Text
+ :> QueryParam "type" Text
+ :> QueryParam "sort" Text
+ :> Get '[Lucid.HTML] TaskListPage
+ :<|> "kb" :> Get '[Lucid.HTML] KBPage
+ :<|> "kb" :> "create" :> ReqBody '[FormUrlEncoded] FactCreateForm :> PostRedirect
+ :<|> "kb" :> Capture "id" Int :> Get '[Lucid.HTML] FactDetailPage
+ :<|> "kb" :> Capture "id" Int :> "edit" :> ReqBody '[FormUrlEncoded] FactEditForm :> PostRedirect
+ :<|> "kb" :> Capture "id" Int :> "delete" :> PostRedirect
+ :<|> "epics" :> QueryParam "sort" Text :> Get '[Lucid.HTML] EpicsPage
+ :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
+ :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> Post '[Lucid.HTML] StatusBadgePartial
+ :<|> "tasks" :> Capture "id" Text :> "priority" :> ReqBody '[FormUrlEncoded] PriorityForm :> Post '[Lucid.HTML] PriorityBadgePartial
+ :<|> "tasks" :> Capture "id" Text :> "description" :> "view" :> Get '[Lucid.HTML] DescriptionViewPartial
+ :<|> "tasks" :> Capture "id" Text :> "description" :> "edit" :> Get '[Lucid.HTML] DescriptionEditPartial
+ :<|> "tasks" :> Capture "id" Text :> "description" :> ReqBody '[FormUrlEncoded] DescriptionForm :> Post '[Lucid.HTML] DescriptionViewPartial
+ :<|> "tasks" :> Capture "id" Text :> "notes" :> ReqBody '[FormUrlEncoded] NotesForm :> PostRedirect
+ :<|> "tasks" :> Capture "id" Text :> "comment" :> ReqBody '[FormUrlEncoded] CommentForm :> PostRedirect
+ :<|> "tasks" :> Capture "id" Text :> "review" :> Get '[Lucid.HTML] TaskReviewPage
+ :<|> "tasks" :> Capture "id" Text :> "diff" :> Capture "commit" Text :> Get '[Lucid.HTML] TaskDiffPage
+ :<|> "tasks" :> Capture "id" Text :> "accept" :> PostRedirect
+ :<|> "tasks" :> Capture "id" Text :> "reject" :> ReqBody '[FormUrlEncoded] RejectForm :> PostRedirect
+ :<|> "tasks" :> Capture "id" Text :> "reset-retries" :> PostRedirect
+ :<|> "partials" :> "recent-activity-new" :> QueryParam "since" Int :> Get '[Lucid.HTML] RecentActivityNewPartial
+ :<|> "partials" :> "recent-activity-more" :> QueryParam "offset" Int :> Get '[Lucid.HTML] RecentActivityMorePartial
+ :<|> "partials" :> "ready-count" :> Get '[Lucid.HTML] ReadyCountPartial
+ :<|> "partials"
+ :> "task-list"
+ :> QueryParam "status" Text
+ :> QueryParam "priority" Text
+ :> QueryParam "namespace" Text
+ :> QueryParam "type" Text
+ :> QueryParam "sort" Text
+ :> Get '[Lucid.HTML] TaskListPartial
+ :<|> "partials" :> "task" :> Capture "id" Text :> "metrics" :> Get '[Lucid.HTML] TaskMetricsPartial
+ :<|> "partials" :> "task" :> Capture "id" Text :> "events" :> QueryParam "since" Int :> Get '[Lucid.HTML] AgentEventsPartial
+ :<|> "tasks" :> Capture "id" Text :> "events" :> "stream" :> StreamGet NoFraming SSE (SourceIO ByteString)
+
+api :: Proxy API
+api = Proxy
+
+server :: Server API
+server =
+ homeHandler
+ :<|> styleHandler
+ :<|> readyQueueHandler
+ :<|> blockedHandler
+ :<|> interventionHandler
+ :<|> statsHandler
+ :<|> taskListHandler
+ :<|> kbHandler
+ :<|> factCreateHandler
+ :<|> factDetailHandler
+ :<|> factEditHandler
+ :<|> factDeleteHandler
+ :<|> epicsHandler
+ :<|> taskDetailHandler
+ :<|> taskStatusHandler
+ :<|> taskPriorityHandler
+ :<|> descriptionViewHandler
+ :<|> descriptionEditHandler
+ :<|> descriptionPostHandler
+ :<|> taskNotesHandler
+ :<|> taskCommentHandler
+ :<|> taskReviewHandler
+ :<|> taskDiffHandler
+ :<|> taskAcceptHandler
+ :<|> taskRejectHandler
+ :<|> taskResetRetriesHandler
+ :<|> recentActivityNewHandler
+ :<|> recentActivityMoreHandler
+ :<|> readyCountHandler
+ :<|> taskListPartialHandler
+ :<|> taskMetricsPartialHandler
+ :<|> agentEventsPartialHandler
+ :<|> taskEventsStreamHandler
+ where
+ styleHandler :: Servant.Handler LazyText.Text
+ styleHandler = pure Style.css
+
+ homeHandler :: Maybe Text -> Servant.Handler HomePage
+ homeHandler maybeRangeText = do
+ now <- liftIO getCurrentTime
+ let range = parseTimeRange maybeRangeText
+ maybeStart = getTimeRangeStart range now
+ allTasks <- liftIO TaskCore.loadTasks
+ let filteredTasks = case maybeStart of
+ Nothing -> allTasks
+ Just start -> [t | t <- allTasks, TaskCore.taskUpdatedAt t >= start]
+ stats = TaskCore.computeTaskStatsFromList filteredTasks
+ readyTasks <- liftIO TaskCore.getReadyTasks
+ allActivities <- liftIO <| concat </ traverse (TaskCore.getActivitiesForTask <. TaskCore.taskId) allTasks
+ let filteredActivities = case maybeStart of
+ Nothing -> allActivities
+ Just start -> [a | a <- allActivities, TaskCore.activityTimestamp a >= start]
+ globalMetrics = computeMetricsFromActivities filteredTasks filteredActivities
+ sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) filteredTasks
+ recentTasks = take 5 sortedTasks
+ hasMoreRecent = length filteredTasks > 5
+ pure (HomePage stats readyTasks recentTasks hasMoreRecent globalMetrics range now)
+
+ readyQueueHandler :: Maybe Text -> Servant.Handler ReadyQueuePage
+ readyQueueHandler maybeSortText = do
+ now <- liftIO getCurrentTime
+ readyTasks <- liftIO TaskCore.getReadyTasks
+ let sortOrder = parseSortOrder maybeSortText
+ sortedTasks = sortTasks sortOrder readyTasks
+ pure (ReadyQueuePage sortedTasks sortOrder now)
+
+ blockedHandler :: Maybe Text -> Servant.Handler BlockedPage
+ blockedHandler maybeSortText = do
+ now <- liftIO getCurrentTime
+ blockedTasks <- liftIO TaskCore.getBlockedTasks
+ allTasks <- liftIO TaskCore.loadTasks
+ let sortOrder = parseSortOrder maybeSortText
+ tasksWithImpact = [(t, TaskCore.getBlockingImpact allTasks t) | t <- blockedTasks]
+ sorted = List.sortBy (comparing (Down <. snd)) tasksWithImpact
+ pure (BlockedPage sorted sortOrder now)
+
+ interventionHandler :: Maybe Text -> Servant.Handler InterventionPage
+ interventionHandler maybeSortText = do
+ now <- liftIO getCurrentTime
+ actionItems <- liftIO TaskCore.getHumanActionItems
+ let sortOrder = parseSortOrder maybeSortText
+ pure (InterventionPage actionItems sortOrder now)
+
+ statsHandler :: Maybe Text -> Servant.Handler StatsPage
+ statsHandler maybeEpic = do
+ let epicId = emptyToNothing maybeEpic
+ stats <- liftIO <| TaskCore.getTaskStats epicId
+ pure (StatsPage stats epicId)
+
+ taskListHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPage
+ taskListHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText maybeSortText = do
+ now <- liftIO getCurrentTime
+ allTasks <- liftIO TaskCore.loadTasks
+ let maybeStatus = parseStatus =<< emptyToNothing maybeStatusText
+ maybePriority = parsePriority =<< emptyToNothing maybePriorityText
+ maybeType = parseTaskType =<< emptyToNothing maybeTypeText
+ filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) maybeType
+ sortOrder = parseSortOrder maybeSortText
+ filteredTasks = sortTasks sortOrder (applyFilters filters allTasks)
+ pure (TaskListPage filteredTasks filters sortOrder now)
+
+ kbHandler :: Servant.Handler KBPage
+ kbHandler = do
+ facts <- liftIO Fact.getAllFacts
+ pure (KBPage facts)
+
+ factCreateHandler :: FactCreateForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ factCreateHandler (FactCreateForm project content filesText confText) = do
+ let files = filter (not <. Text.null) (Text.splitOn "," (Text.strip filesText))
+ confidence = fromMaybe 0.8 (readMaybe (Text.unpack confText))
+ fid <- liftIO (Fact.createFact project content files Nothing confidence)
+ pure <| addHeader ("/kb/" <> tshow fid) NoContent
+
+ factDetailHandler :: Int -> Servant.Handler FactDetailPage
+ factDetailHandler fid = do
+ now <- liftIO getCurrentTime
+ maybeFact <- liftIO (Fact.getFact fid)
+ case maybeFact of
+ Nothing -> pure (FactDetailNotFound fid)
+ Just fact -> pure (FactDetailFound fact now)
+
+ factEditHandler :: Int -> FactEditForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ factEditHandler fid (FactEditForm content filesText confText) = do
+ let files = filter (not <. Text.null) (Text.splitOn "," (Text.strip filesText))
+ confidence = fromMaybe 0.8 (readMaybe (Text.unpack confText))
+ liftIO (Fact.updateFact fid content files confidence)
+ pure <| addHeader ("/kb/" <> tshow fid) NoContent
+
+ factDeleteHandler :: Int -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ factDeleteHandler fid = do
+ liftIO (Fact.deleteFact fid)
+ pure <| addHeader "/kb" NoContent
+
+ epicsHandler :: Maybe Text -> Servant.Handler EpicsPage
+ epicsHandler maybeSortText = do
+ allTasks <- liftIO TaskCore.loadTasks
+ let epicTasks = filter (\t -> TaskCore.taskType t == TaskCore.Epic) allTasks
+ sortOrder = parseSortOrder maybeSortText
+ sortedEpics = sortTasks sortOrder epicTasks
+ pure (EpicsPage sortedEpics allTasks sortOrder)
+
+ parseStatus :: Text -> Maybe TaskCore.Status
+ parseStatus = readMaybe <. Text.unpack
+
+ parsePriority :: Text -> Maybe TaskCore.Priority
+ parsePriority = readMaybe <. Text.unpack
+
+ parseTaskType :: Text -> Maybe TaskCore.TaskType
+ parseTaskType = readMaybe <. Text.unpack
+
+ emptyToNothing :: Maybe Text -> Maybe Text
+ emptyToNothing (Just t) | Text.null (Text.strip t) = Nothing
+ emptyToNothing x = x
+
+ applyFilters :: TaskFilters -> [TaskCore.Task] -> [TaskCore.Task]
+ applyFilters filters = filter matchesAllFilters
+ where
+ matchesAllFilters task =
+ matchesStatus task
+ && matchesPriority task
+ && matchesNamespace task
+ && matchesType task
+
+ matchesStatus task = case filterStatus filters of
+ Nothing -> True
+ Just s -> TaskCore.taskStatus task == s
+
+ matchesPriority task = case filterPriority filters of
+ Nothing -> True
+ Just p -> TaskCore.taskPriority task == p
+
+ matchesNamespace task = case filterNamespace filters of
+ Nothing -> True
+ Just ns -> case TaskCore.taskNamespace task of
+ Nothing -> False
+ Just taskNs -> ns `Text.isPrefixOf` taskNs
+
+ matchesType task = case filterType filters of
+ Nothing -> True
+ Just t -> TaskCore.taskType task == t
+
+ taskDetailHandler :: Text -> Servant.Handler TaskDetailPage
+ taskDetailHandler tid = do
+ now <- liftIO getCurrentTime
+ tasks <- liftIO TaskCore.loadTasks
+ case TaskCore.findTask tid tasks of
+ Nothing -> pure (TaskDetailNotFound tid)
+ Just task -> do
+ activities <- liftIO (TaskCore.getActivitiesForTask tid)
+ retryCtx <- liftIO (TaskCore.getRetryContext tid)
+ commits <- liftIO (getCommitsForTask tid)
+ aggMetrics <-
+ if TaskCore.taskType task == TaskCore.Epic
+ then Just </ liftIO (TaskCore.getAggregatedMetrics tid)
+ else pure Nothing
+ agentEvents <- liftIO (TaskCore.getAllEventsForTask tid)
+ pure (TaskDetailFound task tasks activities retryCtx commits aggMetrics agentEvents now)
+
+ taskStatusHandler :: Text -> StatusForm -> Servant.Handler StatusBadgePartial
+ taskStatusHandler tid (StatusForm newStatus) = do
+ liftIO <| TaskCore.updateTaskStatusWithActor tid newStatus [] TaskCore.Human
+ pure (StatusBadgePartial newStatus tid)
+
+ taskPriorityHandler :: Text -> PriorityForm -> Servant.Handler PriorityBadgePartial
+ taskPriorityHandler tid (PriorityForm newPriority) = do
+ _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskPriority = newPriority})
+ pure (PriorityBadgePartial newPriority tid)
+
+ descriptionViewHandler :: Text -> Servant.Handler DescriptionViewPartial
+ descriptionViewHandler tid = do
+ tasks <- liftIO TaskCore.loadTasks
+ case TaskCore.findTask tid tasks of
+ Nothing -> throwError err404
+ Just task -> pure (DescriptionViewPartial tid (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
+
+ descriptionEditHandler :: Text -> Servant.Handler DescriptionEditPartial
+ descriptionEditHandler tid = do
+ tasks <- liftIO TaskCore.loadTasks
+ case TaskCore.findTask tid tasks of
+ Nothing -> throwError err404
+ Just task -> pure (DescriptionEditPartial tid (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
+
+ descriptionPostHandler :: Text -> DescriptionForm -> Servant.Handler DescriptionViewPartial
+ descriptionPostHandler tid (DescriptionForm desc) = do
+ let descText = Text.strip desc
+ _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskDescription = descText})
+ tasks <- liftIO TaskCore.loadTasks
+ case TaskCore.findTask tid tasks of
+ Nothing -> throwError err404
+ Just task -> pure (DescriptionViewPartial tid (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
+
+ taskNotesHandler :: Text -> NotesForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ taskNotesHandler tid (NotesForm notes) = do
+ liftIO <| TaskCore.updateRetryNotes tid notes
+ pure <| addHeader ("/tasks/" <> tid) NoContent
+
+ taskCommentHandler :: Text -> CommentForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ taskCommentHandler tid (CommentForm commentText) = do
+ _ <- liftIO (TaskCore.addComment tid commentText TaskCore.Human)
+ pure <| addHeader ("/tasks/" <> tid) NoContent
+
+ taskReviewHandler :: Text -> Servant.Handler TaskReviewPage
+ taskReviewHandler tid = do
+ tasks <- liftIO TaskCore.loadTasks
+ case TaskCore.findTask tid tasks of
+ Nothing -> pure (ReviewPageNotFound tid)
+ Just task -> do
+ reviewInfo <- liftIO <| getReviewInfo tid
+ pure (ReviewPageFound task reviewInfo)
+
+ taskDiffHandler :: Text -> Text -> Servant.Handler TaskDiffPage
+ taskDiffHandler tid commitSha = do
+ diffOutput <- liftIO <| getDiffForCommit commitSha
+ case diffOutput of
+ Nothing -> pure (DiffPageNotFound tid commitSha)
+ Just output -> pure (DiffPageFound tid commitSha output)
+
+ taskAcceptHandler :: Text -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ taskAcceptHandler tid = do
+ liftIO <| do
+ TaskCore.clearRetryContext tid
+ TaskCore.updateTaskStatusWithActor tid TaskCore.Done [] TaskCore.Human
+ pure <| addHeader ("/tasks/" <> tid) NoContent
+
+ taskRejectHandler :: Text -> RejectForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ taskRejectHandler tid (RejectForm maybeNotes) = do
+ liftIO <| do
+ maybeCommit <- findCommitForTask tid
+ let commitSha = fromMaybe "" maybeCommit
+ maybeCtx <- TaskCore.getRetryContext tid
+ let attempt = maybe 1 (\ctx -> TaskCore.retryAttempt ctx + 1) maybeCtx
+ let currentReason = "attempt " <> tshow attempt <> ": rejected: " <> fromMaybe "(no notes)" maybeNotes
+ let accumulatedReason = case maybeCtx of
+ Nothing -> currentReason
+ Just ctx -> TaskCore.retryReason ctx <> "\n" <> currentReason
+ TaskCore.setRetryContext
+ TaskCore.RetryContext
+ { TaskCore.retryTaskId = tid,
+ TaskCore.retryOriginalCommit = commitSha,
+ TaskCore.retryConflictFiles = [],
+ TaskCore.retryAttempt = attempt,
+ TaskCore.retryReason = accumulatedReason,
+ TaskCore.retryNotes = maybeCtx +> TaskCore.retryNotes
+ }
+ TaskCore.updateTaskStatusWithActor tid TaskCore.Open [] TaskCore.Human
+ pure <| addHeader ("/tasks/" <> tid) NoContent
+
+ taskResetRetriesHandler :: Text -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+ taskResetRetriesHandler tid = do
+ liftIO <| do
+ TaskCore.clearRetryContext tid
+ TaskCore.updateTaskStatusWithActor tid TaskCore.Open [] TaskCore.Human
+ pure <| addHeader ("/tasks/" <> tid) NoContent
+
+ recentActivityNewHandler :: Maybe Int -> Servant.Handler RecentActivityNewPartial
+ recentActivityNewHandler maybeSince = do
+ allTasks <- liftIO TaskCore.loadTasks
+ let sinceTime = maybe (posixSecondsToUTCTime 0) (posixSecondsToUTCTime <. fromIntegral) maybeSince
+ sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) allTasks
+ newTasks = filter (\t -> TaskCore.taskUpdatedAt t > sinceTime) sortedTasks
+ newestTs = maybe maybeSince (Just <. taskToUnixTs) (head newTasks)
+ pure (RecentActivityNewPartial newTasks newestTs)
+
+ recentActivityMoreHandler :: Maybe Int -> Servant.Handler RecentActivityMorePartial
+ recentActivityMoreHandler maybeOffset = do
+ allTasks <- liftIO TaskCore.loadTasks
+ let offset = fromMaybe 0 maybeOffset
+ pageSize = 5
+ sortedTasks = List.sortBy (flip compare `on` TaskCore.taskUpdatedAt) allTasks
+ pageTasks = take pageSize <| drop offset sortedTasks
+ hasMore = length sortedTasks > offset + pageSize
+ nextOffset = offset + pageSize
+ pure (RecentActivityMorePartial pageTasks nextOffset hasMore)
+
+ readyCountHandler :: Servant.Handler ReadyCountPartial
+ readyCountHandler = do
+ readyTasks <- liftIO TaskCore.getReadyTasks
+ pure (ReadyCountPartial (length readyTasks))
+
+ taskListPartialHandler :: Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> Servant.Handler TaskListPartial
+ taskListPartialHandler maybeStatusText maybePriorityText maybeNamespace maybeTypeText maybeSortText = do
+ allTasks <- liftIO TaskCore.loadTasks
+ let maybeStatus = parseStatus =<< emptyToNothing maybeStatusText
+ maybePriority = parsePriority =<< emptyToNothing maybePriorityText
+ maybeType = parseTaskType =<< emptyToNothing maybeTypeText
+ filters = TaskFilters maybeStatus maybePriority (emptyToNothing maybeNamespace) maybeType
+ sortOrder = parseSortOrder maybeSortText
+ filteredTasks = sortTasks sortOrder (applyFilters filters allTasks)
+ pure (TaskListPartial filteredTasks)
+
+ taskMetricsPartialHandler :: Text -> Servant.Handler TaskMetricsPartial
+ taskMetricsPartialHandler tid = do
+ now <- liftIO getCurrentTime
+ activities <- liftIO (TaskCore.getActivitiesForTask tid)
+ maybeRetry <- liftIO (TaskCore.getRetryContext tid)
+ pure (TaskMetricsPartial tid activities maybeRetry now)
+
+ agentEventsPartialHandler :: Text -> Maybe Int -> Servant.Handler AgentEventsPartial
+ agentEventsPartialHandler tid _maybeSince = do
+ now <- liftIO getCurrentTime
+ events <- liftIO (TaskCore.getAllEventsForTask tid)
+ tasks <- liftIO TaskCore.loadTasks
+ let isInProgress = case TaskCore.findTask tid tasks of
+ Nothing -> False
+ Just task -> TaskCore.taskStatus task == TaskCore.InProgress
+ pure (AgentEventsPartial events isInProgress now)
+
+ taskEventsStreamHandler :: Text -> Servant.Handler (SourceIO ByteString)
+ taskEventsStreamHandler tid = do
+ maybeSession <- liftIO (TaskCore.getLatestSessionForTask tid)
+ case maybeSession of
+ Nothing -> pure (Source.source [])
+ Just sid -> liftIO (streamAgentEvents tid sid)
+
+streamAgentEvents :: Text -> Text -> IO (SourceIO ByteString)
+streamAgentEvents tid sid = do
+ existingEvents <- TaskCore.getEventsForSession sid
+ let lastId = if null existingEvents then 0 else maximum (map TaskCore.storedEventId existingEvents)
+ let existingSSE = map eventToSSE existingEvents
+ pure <| Source.fromStepT <| streamEventsStep tid sid lastId existingSSE True
+
+streamEventsStep :: Text -> Text -> Int -> [ByteString] -> Bool -> Source.StepT IO ByteString
+streamEventsStep tid sid lastId buffer sendExisting = case (sendExisting, buffer) of
+ (True, b : bs) -> Source.Yield b (streamEventsStep tid sid lastId bs True)
+ (True, []) -> streamEventsStep tid sid lastId [] False
+ (False, _) ->
+ Source.Effect <| do
+ tasks <- TaskCore.loadTasks
+ let isComplete = case TaskCore.findTask tid tasks of
+ Nothing -> True
+ Just task -> TaskCore.taskStatus task /= TaskCore.InProgress
+
+ if isComplete
+ then do
+ let completeSSE = formatSSE "complete" "{}"
+ pure <| Source.Yield completeSSE Source.Stop
+ else do
+ Concurrent.threadDelay 500000
+ newEvents <- TaskCore.getEventsSince sid lastId
+ if null newEvents
+ then pure <| streamEventsStep tid sid lastId [] False
+ else do
+ let newLastId = maximum (map TaskCore.storedEventId newEvents)
+ let newSSE = map eventToSSE newEvents
+ case newSSE of
+ (e : es) -> pure <| Source.Yield e (streamEventsStep tid sid newLastId es False)
+ [] -> pure <| streamEventsStep tid sid newLastId [] False
+
+eventToSSE :: TaskCore.StoredEvent -> ByteString
+eventToSSE event =
+ let eventType = Text.toLower (TaskCore.storedEventType event)
+ content = TaskCore.storedEventContent event
+ jsonData = case eventType of
+ "assistant" -> Aeson.object ["content" Aeson..= content]
+ "toolcall" ->
+ let (tool, args) = parseToolCallContent content
+ in Aeson.object ["tool" Aeson..= tool, "args" Aeson..= Aeson.object ["data" Aeson..= args]]
+ "toolresult" ->
+ Aeson.object ["tool" Aeson..= ("unknown" :: Text), "success" Aeson..= True, "output" Aeson..= content]
+ "cost" -> Aeson.object ["cost" Aeson..= content]
+ "error" -> Aeson.object ["error" Aeson..= content]
+ "complete" -> Aeson.object []
+ _ -> Aeson.object ["content" Aeson..= content]
+ in formatSSE eventType (str (Aeson.encode jsonData))
+
+formatSSE :: Text -> ByteString -> ByteString
+formatSSE eventType jsonData =
+ str
+ <| "event: "
+ <> eventType
+ <> "\n"
+ <> "data: "
+ <> str jsonData
+ <> "\n\n"
+
+parseToolCallContent :: Text -> (Text, Text)
+parseToolCallContent content =
+ case Text.breakOn ":" content of
+ (name, rest)
+ | Text.null rest -> (content, "")
+ | otherwise -> (Text.strip name, Text.strip (Text.drop 1 rest))
+
+taskToUnixTs :: TaskCore.Task -> Int
+taskToUnixTs t = ceiling (utcTimeToPOSIXSeconds (TaskCore.taskUpdatedAt t))
+
+getReviewInfo :: Text -> IO ReviewInfo
+getReviewInfo tid = do
+ maybeCommit <- findCommitForTask tid
+ case maybeCommit of
+ Nothing -> pure ReviewNoCommit
+ Just commitSha -> do
+ conflictResult <- checkMergeConflict (Text.unpack commitSha)
+ case conflictResult of
+ Just conflictFiles -> pure (ReviewMergeConflict commitSha conflictFiles)
+ Nothing -> do
+ (_, diffOut, _) <-
+ Process.readProcessWithExitCode
+ "git"
+ ["show", Text.unpack commitSha]
+ ""
+ pure (ReviewReady commitSha (Text.pack diffOut))
+
+getDiffForCommit :: Text -> IO (Maybe Text)
+getDiffForCommit commitSha = do
+ (code, diffOut, _) <-
+ Process.readProcessWithExitCode
+ "git"
+ ["show", Text.unpack commitSha]
+ ""
+ case code of
+ Exit.ExitSuccess -> pure (Just (Text.pack diffOut))
+ Exit.ExitFailure _ -> pure Nothing
+
+findCommitForTask :: Text -> IO (Maybe Text)
+findCommitForTask tid = do
+ let grepArg = "--grep=" <> Text.unpack tid
+ (code, shaOut, _) <-
+ Process.readProcessWithExitCode
+ "git"
+ ["log", "--pretty=format:%H", "-n", "1", grepArg]
+ ""
+ if code /= Exit.ExitSuccess || null shaOut
+ then pure Nothing
+ else case List.lines shaOut of
+ (x : _) -> pure (Just (Text.pack x))
+ [] -> pure Nothing
+
+getCommitsForTask :: Text -> IO [GitCommit]
+getCommitsForTask tid = do
+ let grepArg = "--grep=Task-Id: " <> Text.unpack tid
+ (code, out, _) <-
+ Process.readProcessWithExitCode
+ "git"
+ ["log", "--pretty=format:%H|%h|%s|%an|%ar", grepArg]
+ ""
+ if code /= Exit.ExitSuccess || null out
+ then pure []
+ else do
+ let commitLines = filter (not <. null) (List.lines out)
+ traverse parseCommitLine commitLines
+ where
+ parseCommitLine :: String -> IO GitCommit
+ parseCommitLine line =
+ case Text.splitOn "|" (Text.pack line) of
+ [sha, shortSha, summary, author, relDate] -> do
+ filesCount <- getFilesChangedCount (Text.unpack sha)
+ pure
+ GitCommit
+ { commitHash = sha,
+ commitShortHash = shortSha,
+ commitSummary = summary,
+ commitAuthor = author,
+ commitRelativeDate = relDate,
+ commitFilesChanged = filesCount
+ }
+ _ ->
+ pure
+ GitCommit
+ { commitHash = Text.pack line,
+ commitShortHash = Text.take 7 (Text.pack line),
+ commitSummary = "(parse error)",
+ commitAuthor = "",
+ commitRelativeDate = "",
+ commitFilesChanged = 0
+ }
+
+ getFilesChangedCount :: String -> IO Int
+ getFilesChangedCount sha = do
+ (code', out', _) <-
+ Process.readProcessWithExitCode
+ "git"
+ ["show", "--stat", "--format=", sha]
+ ""
+ pure
+ <| if code' /= Exit.ExitSuccess
+ then 0
+ else
+ let statLines = filter (not <. null) (List.lines out')
+ in max 0 (length statLines - 1)
+
+checkMergeConflict :: String -> IO (Maybe [Text])
+checkMergeConflict commitSha = do
+ (_, origHead, _) <- Process.readProcessWithExitCode "git" ["rev-parse", "HEAD"] ""
+
+ (cpCode, _, cpErr) <-
+ Process.readProcessWithExitCode
+ "git"
+ ["cherry-pick", "--no-commit", commitSha]
+ ""
+
+ _ <- Process.readProcessWithExitCode "git" ["cherry-pick", "--abort"] ""
+ _ <- Process.readProcessWithExitCode "git" ["reset", "--hard", List.head (List.lines origHead)] ""
+
+ case cpCode of
+ Exit.ExitSuccess -> pure Nothing
+ Exit.ExitFailure _ -> do
+ let errLines = Text.lines (Text.pack cpErr)
+ conflictLines = filter (Text.isPrefixOf "CONFLICT") errLines
+ files = mapMaybe extractConflictFile conflictLines
+ pure (Just (if null files then ["(unknown files)"] else files))
+
+extractConflictFile :: Text -> Maybe Text
+extractConflictFile line =
+ case Text.breakOn "Merge conflict in " line of
+ (_, rest)
+ | not (Text.null rest) -> Just (Text.strip (Text.drop 18 rest))
+ _ -> case Text.breakOn "in " line of
+ (_, rest)
+ | not (Text.null rest) -> Just (Text.strip (Text.drop 3 rest))
+ _ -> Nothing
diff --git a/Omni/Jr/Web/Pages.hs b/Omni/Jr/Web/Pages.hs
new file mode 100644
index 0000000..8e335ea
--- /dev/null
+++ b/Omni/Jr/Web/Pages.hs
@@ -0,0 +1,866 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- : dep lucid
+-- : dep servant-lucid
+module Omni.Jr.Web.Pages
+ ( -- * Re-export page types
+ module Omni.Jr.Web.Types,
+ )
+where
+
+import Alpha
+import Data.List (init)
+import qualified Data.Text as Text
+import Data.Time (UTCTime)
+import qualified Lucid
+import qualified Lucid.Base as Lucid
+import Numeric (showFFloat)
+import Omni.Jr.Web.Components
+ ( Breadcrumb (..),
+ SortOrder (..),
+ complexityBadge,
+ epicProgressBar,
+ getDescendants,
+ metaSep,
+ multiColorProgressBar,
+ pageBody,
+ pageBodyWithCrumbs,
+ pageHead,
+ priorityBadgeWithForm,
+ renderAggregatedMetrics,
+ renderBlockedTaskCard,
+ renderEpicCardWithStats,
+ renderEpicReviewCard,
+ renderListGroupItem,
+ renderRelativeTimestamp,
+ renderRetryContextBanner,
+ renderTaskCard,
+ renderUnifiedTimeline,
+ sortDropdown,
+ sortOrderToParam,
+ statusBadge,
+ statusBadgeWithForm,
+ taskBreadcrumbs,
+ )
+import Omni.Jr.Web.Types
+ ( BlockedPage (..),
+ DescriptionViewPartial (..),
+ EpicsPage (..),
+ FactDetailPage (..),
+ GitCommit (..),
+ HomePage (..),
+ InterventionPage (..),
+ KBPage (..),
+ ReadyQueuePage (..),
+ ReviewInfo (..),
+ StatsPage (..),
+ TaskDetailPage (..),
+ TaskDiffPage (..),
+ TaskFilters (..),
+ TaskListPage (..),
+ TaskReviewPage (..),
+ TimeRange (..),
+ filterNamespace,
+ filterPriority,
+ filterStatus,
+ sortTasks,
+ timeRangeToParam,
+ )
+import qualified Omni.Task.Core as TaskCore
+
+taskToUnixTs :: TaskCore.Task -> Int
+taskToUnixTs t =
+ let ts = TaskCore.taskUpdatedAt t
+ in floor (realToFrac (Data.Time.utctDayTime ts) :: Double)
+
+instance Lucid.ToHtml HomePage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (HomePage stats readyTasks recentTasks hasMoreRecent globalMetrics currentRange _now) =
+ Lucid.doctypehtml_ <| do
+ pageHead "Jr Dashboard"
+ pageBody <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h2_ "Task Status"
+ Lucid.div_ [Lucid.class_ "time-filter"] <| do
+ timeFilterBtn "Today" Today currentRange
+ timeFilterBtn "This Week" Week currentRange
+ timeFilterBtn "This Month" Month currentRange
+ timeFilterBtn "All Time" AllTime currentRange
+ Lucid.div_ [Lucid.class_ "stats-grid"] <| do
+ statCard "Open" (TaskCore.openTasks stats) "badge-open" "/tasks?status=Open"
+ statCard "In Progress" (TaskCore.inProgressTasks stats) "badge-inprogress" "/tasks?status=InProgress"
+ statCard "Review" (TaskCore.reviewTasks stats) "badge-review" "/tasks?status=Review"
+ statCard "Approved" (TaskCore.approvedTasks stats) "badge-approved" "/tasks?status=Approved"
+ statCard "Done" (TaskCore.doneTasks stats) "badge-done" "/tasks?status=Done"
+ metricCard "Cost" (formatCost (TaskCore.aggTotalCostCents globalMetrics))
+ metricCard "Duration" (formatDuration (TaskCore.aggTotalDurationSeconds globalMetrics))
+
+ Lucid.h2_ <| do
+ "Ready Queue "
+ Lucid.span_
+ [ Lucid.class_ "ready-count",
+ Lucid.makeAttribute "hx-get" "/partials/ready-count",
+ Lucid.makeAttribute "hx-trigger" "every 5s"
+ ]
+ <| do
+ Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"]
+ <| Lucid.toHtml ("(" <> tshow (length readyTasks) <> " tasks)")
+ if null readyTasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks ready for work."
+ else
+ Lucid.div_ [Lucid.class_ "list-group"]
+ <| traverse_ renderListGroupItem (take 5 readyTasks)
+
+ Lucid.h2_ "Recent Activity"
+ let newestTimestamp = maybe 0 taskToUnixTs (head recentTasks)
+ Lucid.div_
+ [ Lucid.class_ "recent-activity",
+ Lucid.id_ "recent-activity",
+ Lucid.makeAttribute "data-newest-ts" (tshow newestTimestamp),
+ Lucid.makeAttribute "hx-get" "/partials/recent-activity-new",
+ Lucid.makeAttribute "hx-trigger" "every 10s",
+ Lucid.makeAttribute "hx-vals" "js:{since: document.getElementById('recent-activity')?.dataset?.newestTs || 0}",
+ Lucid.makeAttribute "hx-target" "#activity-list",
+ Lucid.makeAttribute "hx-swap" "afterbegin"
+ ]
+ <| do
+ Lucid.div_ [Lucid.id_ "activity-list", Lucid.class_ "list-group"]
+ <| traverse_ renderListGroupItem recentTasks
+ when hasMoreRecent
+ <| Lucid.button_
+ [ Lucid.id_ "activity-load-more",
+ Lucid.class_ "btn btn-secondary load-more-btn",
+ Lucid.makeAttribute "hx-get" "/partials/recent-activity-more?offset=5",
+ Lucid.makeAttribute "hx-target" "#activity-list",
+ Lucid.makeAttribute "hx-swap" "beforeend"
+ ]
+ "Load More"
+ where
+ statCard :: (Monad m) => Text -> Int -> Text -> Text -> Lucid.HtmlT m ()
+ statCard label count badgeClass href =
+ Lucid.a_ [Lucid.href_ href, Lucid.class_ ("stat-card " <> badgeClass)] <| do
+ Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml (tshow count))
+ Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
+
+ metricCard :: (Monad m) => Text -> Text -> Lucid.HtmlT m ()
+ metricCard label value =
+ Lucid.div_ [Lucid.class_ "stat-card badge-neutral"] <| do
+ Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml value)
+ Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
+
+ formatCost :: Int -> Text
+ formatCost cents =
+ let dollars = fromIntegral cents / 100.0 :: Double
+ in Text.pack ("$" <> showFFloat (Just 2) dollars "")
+
+ formatDuration :: Int -> Text
+ formatDuration totalSeconds
+ | totalSeconds < 60 = tshow totalSeconds <> "s"
+ | totalSeconds < 3600 =
+ let mins = totalSeconds `div` 60
+ in tshow mins <> "m"
+ | otherwise =
+ let hours = totalSeconds `div` 3600
+ mins = (totalSeconds `mod` 3600) `div` 60
+ in tshow hours <> "h " <> tshow mins <> "m"
+
+ timeFilterBtn :: (Monad m) => Text -> TimeRange -> TimeRange -> Lucid.HtmlT m ()
+ timeFilterBtn label range current =
+ let activeClass = if range == current then " active" else ""
+ href = "/?" <> "range=" <> timeRangeToParam range
+ in Lucid.a_
+ [ Lucid.href_ href,
+ Lucid.class_ ("time-filter-btn" <> activeClass)
+ ]
+ (Lucid.toHtml label)
+
+instance Lucid.ToHtml ReadyQueuePage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (ReadyQueuePage tasks currentSort _now) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Ready Queue" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Ready Queue - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+ Lucid.h1_ <| Lucid.toHtml ("Ready Queue (" <> tshow (length tasks) <> " tasks)")
+ sortDropdown "/ready" currentSort
+ if null tasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks are ready for work."
+ else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks
+
+instance Lucid.ToHtml BlockedPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (BlockedPage tasksWithImpact currentSort _now) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Blocked" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Blocked Tasks - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+ Lucid.h1_ <| Lucid.toHtml ("Blocked Tasks (" <> tshow (length tasksWithImpact) <> " tasks)")
+ sortDropdown "/blocked" currentSort
+ Lucid.p_ [Lucid.class_ "info-msg"] "Tasks with unmet blocking dependencies, sorted by blocking impact."
+ if null tasksWithImpact
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No blocked tasks."
+ else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderBlockedTaskCard tasksWithImpact
+
+instance Lucid.ToHtml InterventionPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (InterventionPage actionItems currentSort _now) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Needs Human Action" Nothing]
+ failed = TaskCore.failedTasks actionItems
+ epicsReady = TaskCore.epicsInReview actionItems
+ needsHelp = TaskCore.tasksNeedingHelp actionItems
+ totalCount = length failed + length epicsReady + length needsHelp
+ in Lucid.doctypehtml_ <| do
+ pageHead "Needs Human Action - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+ Lucid.h1_ <| Lucid.toHtml ("Needs Human Action (" <> tshow totalCount <> " items)")
+ sortDropdown "/intervention" currentSort
+ if totalCount == 0
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No items need human action."
+ else do
+ unless (null failed) <| do
+ Lucid.h2_ [Lucid.class_ "section-header"] <| Lucid.toHtml ("Failed Tasks (" <> tshow (length failed) <> ")")
+ Lucid.p_ [Lucid.class_ "info-msg"] "Tasks that have failed 3+ times and need human help."
+ Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard (sortTasks currentSort failed)
+ unless (null epicsReady) <| do
+ Lucid.h2_ [Lucid.class_ "section-header"] <| Lucid.toHtml ("Epics Ready for Review (" <> tshow (length epicsReady) <> ")")
+ Lucid.p_ [Lucid.class_ "info-msg"] "Epics with all children completed. Verify before closing."
+ Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderEpicReviewCard epicsReady
+ unless (null needsHelp) <| do
+ Lucid.h2_ [Lucid.class_ "section-header"] <| Lucid.toHtml ("Needs Help (" <> tshow (length needsHelp) <> ")")
+ Lucid.p_ [Lucid.class_ "info-msg"] "Tasks where Jr needs human guidance or decisions."
+ Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard (sortTasks currentSort needsHelp)
+
+instance Lucid.ToHtml KBPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (KBPage facts) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Knowledge Base" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Knowledge Base - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ "Knowledge Base"
+ Lucid.p_ [Lucid.class_ "info-msg"] "Facts learned during task execution."
+
+ Lucid.details_ [Lucid.class_ "create-fact-section"] <| do
+ Lucid.summary_ [Lucid.class_ "btn btn-primary create-fact-toggle"] "Create New Fact"
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ "/kb/create",
+ Lucid.class_ "fact-create-form"
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "project"] "Project:"
+ Lucid.input_
+ [ Lucid.type_ "text",
+ Lucid.name_ "project",
+ Lucid.id_ "project",
+ Lucid.class_ "form-input",
+ Lucid.required_ "required",
+ Lucid.placeholder_ "e.g., Omni/Jr"
+ ]
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "content"] "Fact Content:"
+ Lucid.textarea_
+ [ Lucid.name_ "content",
+ Lucid.id_ "content",
+ Lucid.class_ "form-textarea",
+ Lucid.rows_ "4",
+ Lucid.required_ "required",
+ Lucid.placeholder_ "Describe the fact or knowledge..."
+ ]
+ ""
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "files"] "Related Files (comma-separated):"
+ Lucid.input_
+ [ Lucid.type_ "text",
+ Lucid.name_ "files",
+ Lucid.id_ "files",
+ Lucid.class_ "form-input",
+ Lucid.placeholder_ "path/to/file1.hs, path/to/file2.hs"
+ ]
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "confidence"] "Confidence (0.0 - 1.0):"
+ Lucid.input_
+ [ Lucid.type_ "number",
+ Lucid.name_ "confidence",
+ Lucid.id_ "confidence",
+ Lucid.class_ "form-input",
+ Lucid.step_ "0.1",
+ Lucid.min_ "0",
+ Lucid.max_ "1",
+ Lucid.value_ "0.8"
+ ]
+ Lucid.div_ [Lucid.class_ "form-actions"] <| do
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Create Fact"
+
+ if null facts
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No facts recorded yet."
+ else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderFactCard facts
+ where
+ renderFactCard :: (Monad m) => TaskCore.Fact -> Lucid.HtmlT m ()
+ renderFactCard f =
+ let factUrl = "/kb/" <> maybe "-" tshow (TaskCore.factId f)
+ in Lucid.a_
+ [ Lucid.class_ "task-card task-card-link",
+ Lucid.href_ factUrl
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "task-header"] <| do
+ Lucid.span_ [Lucid.class_ "task-id"] (Lucid.toHtml (maybe "-" tshow (TaskCore.factId f)))
+ confidenceBadge (TaskCore.factConfidence f)
+ Lucid.span_ [Lucid.class_ "priority"] (Lucid.toHtml (TaskCore.factProject f))
+ Lucid.p_ [Lucid.class_ "task-title"] (Lucid.toHtml (Text.take 80 (TaskCore.factContent f) <> if Text.length (TaskCore.factContent f) > 80 then "..." else ""))
+ unless (null (TaskCore.factRelatedFiles f)) <| do
+ Lucid.p_ [Lucid.class_ "kb-files"] <| do
+ Lucid.span_ [Lucid.class_ "files-label"] "Files: "
+ Lucid.toHtml (Text.intercalate ", " (take 3 (TaskCore.factRelatedFiles f)))
+ when (length (TaskCore.factRelatedFiles f) > 3) <| do
+ Lucid.toHtml (" +" <> tshow (length (TaskCore.factRelatedFiles f) - 3) <> " more")
+
+ confidenceBadge :: (Monad m) => Double -> Lucid.HtmlT m ()
+ confidenceBadge conf =
+ let pct = floor (conf * 100) :: Int
+ cls
+ | conf >= 0.8 = "badge badge-done"
+ | conf >= 0.5 = "badge badge-inprogress"
+ | otherwise = "badge badge-open"
+ in Lucid.span_ [Lucid.class_ cls] (Lucid.toHtml (tshow pct <> "%"))
+
+instance Lucid.ToHtml FactDetailPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (FactDetailNotFound fid) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Knowledge Base" (Just "/kb"), Breadcrumb ("Fact #" <> tshow fid) Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Fact Not Found - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ "Fact Not Found"
+ Lucid.p_ [Lucid.class_ "error-msg"] (Lucid.toHtml ("Fact with ID " <> tshow fid <> " not found."))
+ Lucid.a_ [Lucid.href_ "/kb", Lucid.class_ "btn btn-secondary"] "Back to Knowledge Base"
+ toHtml (FactDetailFound fact now) =
+ let fid' = maybe "-" tshow (TaskCore.factId fact)
+ crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Knowledge Base" (Just "/kb"), Breadcrumb ("Fact #" <> fid') Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Fact Detail - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "task-detail-header"] <| do
+ Lucid.h1_ <| do
+ Lucid.span_ [Lucid.class_ "detail-id"] (Lucid.toHtml ("Fact #" <> maybe "-" tshow (TaskCore.factId fact)))
+ Lucid.div_ [Lucid.class_ "task-meta-row"] <| do
+ Lucid.span_ [Lucid.class_ "meta-label"] "Project:"
+ Lucid.span_ [Lucid.class_ "meta-value"] (Lucid.toHtml (TaskCore.factProject fact))
+ Lucid.span_ [Lucid.class_ "meta-label"] "Confidence:"
+ confidenceBadgeDetail (TaskCore.factConfidence fact)
+ Lucid.span_ [Lucid.class_ "meta-label"] "Created:"
+ Lucid.span_ [Lucid.class_ "meta-value"] (renderRelativeTimestamp now (TaskCore.factCreatedAt fact))
+
+ Lucid.div_ [Lucid.class_ "detail-section"] <| do
+ Lucid.h2_ "Content"
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ ("/kb/" <> maybe "-" tshow (TaskCore.factId fact) <> "/edit"),
+ Lucid.class_ "fact-edit-form"
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "content"] "Fact Content:"
+ Lucid.textarea_
+ [ Lucid.name_ "content",
+ Lucid.id_ "content",
+ Lucid.class_ "form-textarea",
+ Lucid.rows_ "6"
+ ]
+ (Lucid.toHtml (TaskCore.factContent fact))
+
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "files"] "Related Files (comma-separated):"
+ Lucid.input_
+ [ Lucid.type_ "text",
+ Lucid.name_ "files",
+ Lucid.id_ "files",
+ Lucid.class_ "form-input",
+ Lucid.value_ (Text.intercalate ", " (TaskCore.factRelatedFiles fact))
+ ]
+
+ Lucid.div_ [Lucid.class_ "form-group"] <| do
+ Lucid.label_ [Lucid.for_ "confidence"] "Confidence (0.0 - 1.0):"
+ Lucid.input_
+ [ Lucid.type_ "number",
+ Lucid.name_ "confidence",
+ Lucid.id_ "confidence",
+ Lucid.class_ "form-input",
+ Lucid.step_ "0.1",
+ Lucid.min_ "0",
+ Lucid.max_ "1",
+ Lucid.value_ (tshow (TaskCore.factConfidence fact))
+ ]
+
+ Lucid.div_ [Lucid.class_ "form-actions"] <| do
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Save Changes"
+
+ case TaskCore.factSourceTask fact of
+ Nothing -> pure ()
+ Just tid -> do
+ Lucid.div_ [Lucid.class_ "detail-section"] <| do
+ Lucid.h2_ "Source Task"
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> tid), Lucid.class_ "task-link"] (Lucid.toHtml tid)
+
+ Lucid.div_ [Lucid.class_ "detail-section danger-zone"] <| do
+ Lucid.h2_ "Danger Zone"
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ ("/kb/" <> maybe "-" tshow (TaskCore.factId fact) <> "/delete"),
+ Lucid.class_ "delete-form",
+ Lucid.makeAttribute "onsubmit" "return confirm('Are you sure you want to delete this fact?');"
+ ]
+ <| do
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-danger"] "Delete Fact"
+
+ Lucid.div_ [Lucid.class_ "back-link"] <| do
+ Lucid.a_ [Lucid.href_ "/kb"] "← Back to Knowledge Base"
+ where
+ confidenceBadgeDetail :: (Monad m) => Double -> Lucid.HtmlT m ()
+ confidenceBadgeDetail conf =
+ let pct = floor (conf * 100) :: Int
+ cls
+ | conf >= 0.8 = "badge badge-done"
+ | conf >= 0.5 = "badge badge-inprogress"
+ | otherwise = "badge badge-open"
+ in Lucid.span_ [Lucid.class_ cls] (Lucid.toHtml (tshow pct <> "%"))
+
+instance Lucid.ToHtml EpicsPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (EpicsPage epics allTasks currentSort) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Epics" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Epics - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+ Lucid.h1_ <| Lucid.toHtml ("Epics (" <> tshow (length epics) <> ")")
+ sortDropdown "/epics" currentSort
+ Lucid.p_ [Lucid.class_ "info-msg"] "All epics (large, multi-task projects)."
+ if null epics
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No epics found."
+ else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ (renderEpicCardWithStats allTasks) epics
+
+instance Lucid.ToHtml TaskListPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (TaskListPage tasks filters currentSort _now) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Tasks - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "page-header-row"] <| do
+ Lucid.h1_ <| Lucid.toHtml ("Tasks (" <> tshow (length tasks) <> ")")
+ sortDropdown "/tasks" currentSort
+
+ Lucid.div_ [Lucid.class_ "filter-form"] <| do
+ Lucid.form_
+ [ Lucid.method_ "GET",
+ Lucid.action_ "/tasks",
+ Lucid.makeAttribute "hx-get" "/partials/task-list",
+ Lucid.makeAttribute "hx-target" "#task-list",
+ Lucid.makeAttribute "hx-push-url" "/tasks",
+ Lucid.makeAttribute "hx-trigger" "submit, change from:select"
+ ]
+ <| do
+ Lucid.div_ [Lucid.class_ "filter-row"] <| do
+ Lucid.div_ [Lucid.class_ "filter-group"] <| do
+ Lucid.label_ [Lucid.for_ "status"] "Status:"
+ Lucid.select_ [Lucid.name_ "status", Lucid.id_ "status", Lucid.class_ "filter-select"] <| do
+ Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterStatus filters)) "All"
+ statusFilterOption TaskCore.Open (filterStatus filters)
+ statusFilterOption TaskCore.InProgress (filterStatus filters)
+ statusFilterOption TaskCore.Review (filterStatus filters)
+ statusFilterOption TaskCore.Approved (filterStatus filters)
+ statusFilterOption TaskCore.Done (filterStatus filters)
+
+ Lucid.div_ [Lucid.class_ "filter-group"] <| do
+ Lucid.label_ [Lucid.for_ "priority"] "Priority:"
+ Lucid.select_ [Lucid.name_ "priority", Lucid.id_ "priority", Lucid.class_ "filter-select"] <| do
+ Lucid.option_ ([Lucid.value_ ""] <> maybeSelected Nothing (filterPriority filters)) "All"
+ priorityFilterOption TaskCore.P0 (filterPriority filters)
+ priorityFilterOption TaskCore.P1 (filterPriority filters)
+ priorityFilterOption TaskCore.P2 (filterPriority filters)
+ priorityFilterOption TaskCore.P3 (filterPriority filters)
+ priorityFilterOption TaskCore.P4 (filterPriority filters)
+
+ Lucid.div_ [Lucid.class_ "filter-group"] <| do
+ Lucid.label_ [Lucid.for_ "namespace"] "Namespace:"
+ Lucid.input_
+ [ Lucid.type_ "text",
+ Lucid.name_ "namespace",
+ Lucid.id_ "namespace",
+ Lucid.class_ "filter-input",
+ Lucid.placeholder_ "e.g. Omni/Jr",
+ Lucid.value_ (fromMaybe "" (filterNamespace filters))
+ ]
+
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter"
+ Lucid.a_
+ [ Lucid.href_ "/tasks",
+ Lucid.class_ "clear-btn",
+ Lucid.makeAttribute "hx-get" "/partials/task-list",
+ Lucid.makeAttribute "hx-target" "#task-list",
+ Lucid.makeAttribute "hx-push-url" "/tasks"
+ ]
+ "Clear"
+
+ Lucid.div_ [Lucid.id_ "task-list"] <| do
+ if null tasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters."
+ else Lucid.div_ [Lucid.class_ "list-group"] <| traverse_ renderListGroupItem tasks
+ where
+ maybeSelected :: (Eq a) => Maybe a -> Maybe a -> [Lucid.Attribute]
+ maybeSelected opt current = [Lucid.selected_ "selected" | opt == current]
+
+ statusFilterOption :: (Monad m) => TaskCore.Status -> Maybe TaskCore.Status -> Lucid.HtmlT m ()
+ statusFilterOption s current =
+ let attrs = [Lucid.value_ (tshow s)] <> [Lucid.selected_ "selected" | Just s == current]
+ in Lucid.option_ attrs (Lucid.toHtml (tshow s))
+
+ priorityFilterOption :: (Monad m) => TaskCore.Priority -> Maybe TaskCore.Priority -> Lucid.HtmlT m ()
+ priorityFilterOption p current =
+ let attrs = [Lucid.value_ (tshow p)] <> [Lucid.selected_ "selected" | Just p == current]
+ in Lucid.option_ attrs (Lucid.toHtml (tshow p))
+
+instance Lucid.ToHtml TaskDetailPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (TaskDetailNotFound tid) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Task Not Found - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ "Task Not Found"
+ Lucid.p_ <| do
+ "The task "
+ Lucid.code_ (Lucid.toHtml tid)
+ " could not be found."
+ toHtml (TaskDetailFound task allTasks _activities maybeRetry commits maybeAggMetrics agentEvents now) =
+ let crumbs = taskBreadcrumbs allTasks task
+ in Lucid.doctypehtml_ <| do
+ pageHead (TaskCore.taskId task <> " - Jr")
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ <| Lucid.toHtml (TaskCore.taskTitle task)
+
+ renderRetryContextBanner (TaskCore.taskId task) maybeRetry
+
+ Lucid.div_ [Lucid.class_ "task-detail"] <| do
+ Lucid.div_ [Lucid.class_ "task-meta"] <| do
+ Lucid.div_ [Lucid.class_ "task-meta-primary"] <| do
+ Lucid.code_ [Lucid.class_ "task-meta-id"] (Lucid.toHtml (TaskCore.taskId task))
+ metaSep
+ Lucid.span_ [Lucid.class_ "task-meta-type"] (Lucid.toHtml (tshow (TaskCore.taskType task)))
+ metaSep
+ statusBadgeWithForm (TaskCore.taskStatus task) (TaskCore.taskId task)
+ metaSep
+ priorityBadgeWithForm (TaskCore.taskPriority task) (TaskCore.taskId task)
+ case TaskCore.taskComplexity task of
+ Nothing -> pure ()
+ Just c -> do
+ metaSep
+ complexityBadge c
+ case TaskCore.taskNamespace task of
+ Nothing -> pure ()
+ Just ns -> do
+ metaSep
+ Lucid.span_ [Lucid.class_ "task-meta-ns"] (Lucid.toHtml ns)
+
+ Lucid.div_ [Lucid.class_ "task-meta-secondary"] <| do
+ case TaskCore.taskParent task of
+ Nothing -> pure ()
+ Just pid -> do
+ Lucid.span_ [Lucid.class_ "task-meta-label"] "Parent:"
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> pid), Lucid.class_ "task-link"] (Lucid.toHtml pid)
+ metaSep
+ Lucid.span_ [Lucid.class_ "task-meta-label"] "Created"
+ renderRelativeTimestamp now (TaskCore.taskCreatedAt task)
+ metaSep
+ Lucid.span_ [Lucid.class_ "task-meta-label"] "Updated"
+ renderRelativeTimestamp now (TaskCore.taskUpdatedAt task)
+
+ let deps = TaskCore.taskDependencies task
+ unless (null deps) <| do
+ Lucid.div_ [Lucid.class_ "detail-section"] <| do
+ Lucid.h3_ "Dependencies"
+ Lucid.ul_ [Lucid.class_ "dep-list"] <| do
+ traverse_ renderDependency deps
+
+ when (TaskCore.taskType task == TaskCore.Epic) <| do
+ for_ maybeAggMetrics (renderAggregatedMetrics allTasks task)
+
+ Lucid.div_ [Lucid.class_ "detail-section"] <| do
+ Lucid.toHtml (DescriptionViewPartial (TaskCore.taskId task) (TaskCore.taskDescription task) (TaskCore.taskType task == TaskCore.Epic))
+
+ let children = filter (maybe False (TaskCore.matchesId (TaskCore.taskId task)) <. TaskCore.taskParent) allTasks
+ unless (null children) <| do
+ Lucid.div_ [Lucid.class_ "detail-section"] <| do
+ Lucid.h3_ "Child Tasks"
+ Lucid.ul_ [Lucid.class_ "child-list"] <| do
+ traverse_ renderChild children
+
+ unless (null commits) <| do
+ Lucid.div_ [Lucid.class_ "detail-section"] <| do
+ Lucid.h3_ "Git Commits"
+ Lucid.div_ [Lucid.class_ "commit-list"] <| do
+ traverse_ (renderCommit (TaskCore.taskId task)) commits
+
+ when (TaskCore.taskStatus task == TaskCore.Review) <| do
+ Lucid.div_ [Lucid.class_ "review-link-section"] <| do
+ Lucid.a_
+ [ Lucid.href_ ("/tasks/" <> TaskCore.taskId task <> "/review"),
+ Lucid.class_ "review-link-btn"
+ ]
+ "Review This Task"
+
+ renderUnifiedTimeline (TaskCore.taskId task) (TaskCore.taskComments task) agentEvents (TaskCore.taskStatus task) now
+ where
+ renderDependency :: (Monad m) => TaskCore.Dependency -> Lucid.HtmlT m ()
+ renderDependency dep =
+ Lucid.li_ <| do
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> TaskCore.depId dep), Lucid.class_ "task-link"] (Lucid.toHtml (TaskCore.depId dep))
+ Lucid.span_ [Lucid.class_ "dep-type"] <| Lucid.toHtml (" [" <> tshow (TaskCore.depType dep) <> "]")
+
+ renderChild :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m ()
+ renderChild child =
+ Lucid.li_ <| do
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> TaskCore.taskId child), Lucid.class_ "task-link"] (Lucid.toHtml (TaskCore.taskId child))
+ Lucid.span_ [Lucid.class_ "child-title"] <| Lucid.toHtml (" - " <> TaskCore.taskTitle child)
+ Lucid.span_ [Lucid.class_ "child-status"] <| Lucid.toHtml (" [" <> tshow (TaskCore.taskStatus child) <> "]")
+
+ renderCommit :: (Monad m) => Text -> GitCommit -> Lucid.HtmlT m ()
+ renderCommit tid c =
+ Lucid.div_ [Lucid.class_ "commit-item"] <| do
+ Lucid.div_ [Lucid.class_ "commit-header"] <| do
+ Lucid.a_
+ [ Lucid.href_ ("/tasks/" <> tid <> "/diff/" <> commitHash c),
+ Lucid.class_ "commit-hash"
+ ]
+ (Lucid.toHtml (commitShortHash c))
+ Lucid.span_ [Lucid.class_ "commit-summary"] (Lucid.toHtml (commitSummary c))
+ Lucid.div_ [Lucid.class_ "commit-meta"] <| do
+ Lucid.span_ [Lucid.class_ "commit-author"] (Lucid.toHtml (commitAuthor c))
+ Lucid.span_ [Lucid.class_ "commit-date"] (Lucid.toHtml (commitRelativeDate c))
+ Lucid.span_ [Lucid.class_ "commit-files"] (Lucid.toHtml (tshow (commitFilesChanged c) <> " files"))
+
+instance Lucid.ToHtml TaskReviewPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (ReviewPageNotFound tid) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb "Review" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Task Not Found - Jr Review"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ "Task Not Found"
+ Lucid.p_ <| do
+ "The task "
+ Lucid.code_ (Lucid.toHtml tid)
+ " could not be found."
+ toHtml (ReviewPageFound task reviewInfo) =
+ let tid = TaskCore.taskId task
+ crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb "Review" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead ("Review: " <> TaskCore.taskId task <> " - Jr")
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ "Review Task"
+
+ Lucid.div_ [Lucid.class_ "task-summary"] <| do
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "ID:"
+ Lucid.code_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskId task))
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "Title:"
+ Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (TaskCore.taskTitle task))
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "Status:"
+ Lucid.span_ [Lucid.class_ "detail-value"] <| statusBadge (TaskCore.taskStatus task)
+
+ case reviewInfo of
+ ReviewNoCommit ->
+ Lucid.div_ [Lucid.class_ "no-commit-msg"] <| do
+ Lucid.h3_ "No Commit Found"
+ Lucid.p_ "No commit with this task ID was found in the git history."
+ Lucid.p_ "The worker may not have completed yet, or the commit message doesn't include the task ID."
+ ReviewMergeConflict commitSha conflictFiles ->
+ Lucid.div_ [Lucid.class_ "conflict-warning"] <| do
+ Lucid.h3_ "Merge Conflict Detected"
+ Lucid.p_ <| do
+ "Commit "
+ Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha))
+ " cannot be cleanly merged."
+ Lucid.p_ "Conflicting files:"
+ Lucid.ul_ <| traverse_ (Lucid.li_ <. Lucid.toHtml) conflictFiles
+ ReviewReady commitSha diffText -> do
+ Lucid.div_ [Lucid.class_ "diff-section"] <| do
+ Lucid.h3_ <| do
+ "Commit: "
+ Lucid.code_ (Lucid.toHtml (Text.take 8 commitSha))
+ Lucid.pre_ [Lucid.class_ "diff-block"] (Lucid.toHtml diffText)
+
+ Lucid.div_ [Lucid.class_ "review-actions"] <| do
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/accept"),
+ Lucid.class_ "inline-form"
+ ]
+ <| do
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "accept-btn"] "Accept"
+
+ Lucid.form_
+ [ Lucid.method_ "POST",
+ Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/reject"),
+ Lucid.class_ "reject-form"
+ ]
+ <| do
+ Lucid.textarea_
+ [ Lucid.name_ "notes",
+ Lucid.class_ "reject-notes",
+ Lucid.placeholder_ "Rejection notes (optional)"
+ ]
+ ""
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "reject-btn"] "Reject"
+
+instance Lucid.ToHtml TaskDiffPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (DiffPageNotFound tid commitHash') =
+ let shortHash = Text.take 8 commitHash'
+ crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb ("Diff " <> shortHash) Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Commit Not Found - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ "Commit Not Found"
+ Lucid.p_ <| do
+ "Could not find commit "
+ Lucid.code_ (Lucid.toHtml commitHash')
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> tid), Lucid.class_ "back-link"] "← Back to task"
+ toHtml (DiffPageFound tid commitHash' diffOutput) =
+ let shortHash = Text.take 8 commitHash'
+ crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Tasks" (Just "/tasks"), Breadcrumb tid (Just ("/tasks/" <> tid)), Breadcrumb ("Diff " <> shortHash) Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead ("Diff " <> shortHash <> " - Jr")
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.div_ [Lucid.class_ "diff-header"] <| do
+ Lucid.a_ [Lucid.href_ ("/tasks/" <> tid), Lucid.class_ "back-link"] "← Back to task"
+ Lucid.h1_ <| do
+ "Commit "
+ Lucid.code_ (Lucid.toHtml shortHash)
+ Lucid.pre_ [Lucid.class_ "diff-block"] (Lucid.toHtml diffOutput)
+
+instance Lucid.ToHtml StatsPage where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (StatsPage stats maybeEpic) =
+ let crumbs = [Breadcrumb "Jr" (Just "/"), Breadcrumb "Stats" Nothing]
+ in Lucid.doctypehtml_ <| do
+ pageHead "Task Statistics - Jr"
+ pageBodyWithCrumbs crumbs <| do
+ Lucid.div_ [Lucid.class_ "container"] <| do
+ Lucid.h1_ <| case maybeEpic of
+ Nothing -> "Task Statistics"
+ Just epicId -> Lucid.toHtml ("Statistics for Epic: " <> epicId)
+
+ Lucid.form_ [Lucid.method_ "GET", Lucid.action_ "/stats", Lucid.class_ "filter-form"] <| do
+ Lucid.div_ [Lucid.class_ "filter-row"] <| do
+ Lucid.div_ [Lucid.class_ "filter-group"] <| do
+ Lucid.label_ [Lucid.for_ "epic"] "Epic:"
+ Lucid.input_
+ [ Lucid.type_ "text",
+ Lucid.name_ "epic",
+ Lucid.id_ "epic",
+ Lucid.class_ "filter-input",
+ Lucid.placeholder_ "Epic ID (optional)",
+ Lucid.value_ (fromMaybe "" maybeEpic)
+ ]
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "filter-btn"] "Filter"
+ Lucid.a_ [Lucid.href_ "/stats", Lucid.class_ "clear-btn"] "Clear"
+
+ Lucid.h2_ "By Status"
+ multiColorProgressBar stats
+ Lucid.div_ [Lucid.class_ "stats-grid"] <| do
+ statCard "Open" (TaskCore.openTasks stats) (TaskCore.totalTasks stats)
+ statCard "In Progress" (TaskCore.inProgressTasks stats) (TaskCore.totalTasks stats)
+ statCard "Review" (TaskCore.reviewTasks stats) (TaskCore.totalTasks stats)
+ statCard "Approved" (TaskCore.approvedTasks stats) (TaskCore.totalTasks stats)
+ statCard "Done" (TaskCore.doneTasks stats) (TaskCore.totalTasks stats)
+
+ Lucid.h2_ "By Priority"
+ Lucid.div_ [Lucid.class_ "stats-section"] <| do
+ traverse_ (uncurry renderPriorityRow) (TaskCore.tasksByPriority stats)
+
+ Lucid.h2_ "By Namespace"
+ Lucid.div_ [Lucid.class_ "stats-section"] <| do
+ if null (TaskCore.tasksByNamespace stats)
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No namespaces found."
+ else traverse_ (uncurry (renderNamespaceRow (TaskCore.totalTasks stats))) (TaskCore.tasksByNamespace stats)
+
+ Lucid.h2_ "Summary"
+ Lucid.div_ [Lucid.class_ "summary-section"] <| do
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "Total Tasks:"
+ Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.totalTasks stats)))
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "Epics:"
+ Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.totalEpics stats)))
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "Ready:"
+ Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.readyTasks stats)))
+ Lucid.div_ [Lucid.class_ "detail-row"] <| do
+ Lucid.span_ [Lucid.class_ "detail-label"] "Blocked:"
+ Lucid.span_ [Lucid.class_ "detail-value"] (Lucid.toHtml (tshow (TaskCore.blockedTasks stats)))
+ where
+ statCard :: (Monad m) => Text -> Int -> Int -> Lucid.HtmlT m ()
+ statCard label count total =
+ let pct = if total == 0 then 0 else (count * 100) `div` total
+ in Lucid.div_ [Lucid.class_ "stat-card"] <| do
+ Lucid.div_ [Lucid.class_ "stat-count"] (Lucid.toHtml (tshow count))
+ Lucid.div_ [Lucid.class_ "stat-label"] (Lucid.toHtml label)
+ Lucid.div_ [Lucid.class_ "progress-bar"] <| do
+ Lucid.div_
+ [ Lucid.class_ "progress-fill",
+ Lucid.style_ ("width: " <> tshow pct <> "%")
+ ]
+ ""
+
+ renderPriorityRow :: (Monad m) => TaskCore.Priority -> Int -> Lucid.HtmlT m ()
+ renderPriorityRow priority count =
+ let total = TaskCore.totalTasks stats
+ pct = if total == 0 then 0 else (count * 100) `div` total
+ in Lucid.div_ [Lucid.class_ "stats-row"] <| do
+ Lucid.span_ [Lucid.class_ "stats-label"] (Lucid.toHtml (tshow priority))
+ Lucid.div_ [Lucid.class_ "stats-bar-container"] <| do
+ Lucid.div_ [Lucid.class_ "progress-bar"] <| do
+ Lucid.div_
+ [ Lucid.class_ "progress-fill",
+ Lucid.style_ ("width: " <> tshow pct <> "%")
+ ]
+ ""
+ Lucid.span_ [Lucid.class_ "stats-count"] (Lucid.toHtml (tshow count))
+
+ renderNamespaceRow :: (Monad m) => Int -> Text -> Int -> Lucid.HtmlT m ()
+ renderNamespaceRow total ns count =
+ let pct = if total == 0 then 0 else (count * 100) `div` total
+ in Lucid.div_ [Lucid.class_ "stats-row"] <| do
+ Lucid.span_ [Lucid.class_ "stats-label"] (Lucid.toHtml ns)
+ Lucid.div_ [Lucid.class_ "stats-bar-container"] <| do
+ Lucid.div_ [Lucid.class_ "progress-bar"] <| do
+ Lucid.div_
+ [ Lucid.class_ "progress-fill",
+ Lucid.style_ ("width: " <> tshow pct <> "%")
+ ]
+ ""
+ Lucid.span_ [Lucid.class_ "stats-count"] (Lucid.toHtml (tshow count))
diff --git a/Omni/Jr/Web/Partials.hs b/Omni/Jr/Web/Partials.hs
new file mode 100644
index 0000000..79c997e
--- /dev/null
+++ b/Omni/Jr/Web/Partials.hs
@@ -0,0 +1,247 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- : dep lucid
+-- : dep servant-lucid
+module Omni.Jr.Web.Partials
+ ( -- Re-export instances for use by Web.hs
+ )
+where
+
+import Alpha
+import qualified Data.Text as Text
+import Data.Time (UTCTime, diffUTCTime)
+import qualified Lucid
+import qualified Lucid.Base as Lucid
+import Numeric (showFFloat)
+import Omni.Jr.Web.Components
+ ( priorityBadgeWithForm,
+ renderListGroupItem,
+ renderMarkdown,
+ renderRelativeTimestamp,
+ renderTimelineEvent,
+ statusBadgeWithForm,
+ timelineScrollScript,
+ )
+import Omni.Jr.Web.Types
+ ( AgentEventsPartial (..),
+ DescriptionEditPartial (..),
+ DescriptionViewPartial (..),
+ PriorityBadgePartial (..),
+ ReadyCountPartial (..),
+ RecentActivityMorePartial (..),
+ RecentActivityNewPartial (..),
+ StatusBadgePartial (..),
+ TaskListPartial (..),
+ TaskMetricsPartial (..),
+ )
+import qualified Omni.Task.Core as TaskCore
+
+instance Lucid.ToHtml RecentActivityNewPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (RecentActivityNewPartial tasks maybeNewestTs) = do
+ traverse_ renderListGroupItem tasks
+ case maybeNewestTs of
+ Nothing -> pure ()
+ Just ts ->
+ Lucid.div_
+ [ Lucid.id_ "recent-activity",
+ Lucid.makeAttribute "data-newest-ts" (tshow ts),
+ Lucid.makeAttribute "hx-swap-oob" "attributes:#recent-activity data-newest-ts"
+ ]
+ ""
+
+instance Lucid.ToHtml RecentActivityMorePartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (RecentActivityMorePartial tasks nextOffset hasMore) = do
+ traverse_ renderListGroupItem tasks
+ if hasMore
+ then
+ Lucid.button_
+ [ Lucid.id_ "activity-load-more",
+ Lucid.class_ "btn btn-secondary load-more-btn",
+ Lucid.makeAttribute "hx-get" ("/partials/recent-activity-more?offset=" <> tshow nextOffset),
+ Lucid.makeAttribute "hx-target" "#activity-list",
+ Lucid.makeAttribute "hx-swap" "beforeend",
+ Lucid.makeAttribute "hx-swap-oob" "true"
+ ]
+ "Load More"
+ else Lucid.span_ [Lucid.id_ "activity-load-more", Lucid.makeAttribute "hx-swap-oob" "true"] ""
+
+instance Lucid.ToHtml ReadyCountPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (ReadyCountPartial count) =
+ Lucid.a_ [Lucid.href_ "/ready", Lucid.class_ "ready-link"]
+ <| Lucid.toHtml ("(" <> tshow count <> " tasks)")
+
+instance Lucid.ToHtml StatusBadgePartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (StatusBadgePartial status tid) =
+ statusBadgeWithForm status tid
+
+instance Lucid.ToHtml PriorityBadgePartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (PriorityBadgePartial priority tid) =
+ priorityBadgeWithForm priority tid
+
+instance Lucid.ToHtml TaskListPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (TaskListPartial tasks) =
+ if null tasks
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters."
+ else Lucid.div_ [Lucid.class_ "list-group"] <| traverse_ renderListGroupItem tasks
+
+instance Lucid.ToHtml TaskMetricsPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (TaskMetricsPartial _tid activities maybeRetry now) =
+ let runningActs = filter (\a -> TaskCore.activityStage a == TaskCore.Running) activities
+ in if null runningActs
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No worker execution data available."
+ else
+ Lucid.div_ [Lucid.class_ "execution-details"] <| do
+ let totalCost = sum [c | act <- runningActs, Just c <- [TaskCore.activityCostCents act]]
+ totalDuration = sum [calcDurSecs act | act <- runningActs]
+ attemptCount = length runningActs
+
+ case maybeRetry of
+ Nothing -> pure ()
+ Just ctx ->
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Retry Attempt:"
+ Lucid.span_ [Lucid.class_ "metric-value retry-count"] (Lucid.toHtml (tshow (TaskCore.retryAttempt ctx) <> "/3"))
+
+ when (attemptCount > 1) <| do
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Total Attempts:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (tshow attemptCount))
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Total Duration:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDurSecs totalDuration))
+ when (totalCost > 0)
+ <| Lucid.div_ [Lucid.class_ "metric-row"]
+ <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Total Cost:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCost totalCost))
+ Lucid.hr_ [Lucid.class_ "attempts-divider"]
+
+ traverse_ (renderAttempt attemptCount now) (zip [1 ..] (reverse runningActs))
+ where
+ calcDurSecs :: TaskCore.TaskActivity -> Int
+ calcDurSecs act = case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of
+ (Just start, Just end) -> floor (diffUTCTime end start)
+ _ -> 0
+
+ formatDurSecs :: Int -> Text
+ formatDurSecs secs
+ | secs < 60 = tshow secs <> "s"
+ | secs < 3600 = tshow (secs `div` 60) <> "m " <> tshow (secs `mod` 60) <> "s"
+ | otherwise = tshow (secs `div` 3600) <> "h " <> tshow ((secs `mod` 3600) `div` 60) <> "m"
+
+ renderAttempt :: (Monad m) => Int -> UTCTime -> (Int, TaskCore.TaskActivity) -> Lucid.HtmlT m ()
+ renderAttempt totalAttempts currentTime (attemptNum, act) = do
+ when (totalAttempts > 1)
+ <| Lucid.div_ [Lucid.class_ "attempt-header"] (Lucid.toHtml ("Attempt " <> tshow attemptNum :: Text))
+ case TaskCore.activityThreadUrl act of
+ Nothing -> pure ()
+ Just url ->
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Session:"
+ Lucid.a_ [Lucid.href_ url, Lucid.target_ "_blank", Lucid.class_ "amp-thread-btn"] "View in Amp ↗"
+
+ case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of
+ (Just start, Just end) ->
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Duration:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatDuration start end))
+ (Just start, Nothing) ->
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Started:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (renderRelativeTimestamp currentTime start)
+ _ -> pure ()
+
+ case TaskCore.activityCostCents act of
+ Nothing -> pure ()
+ Just cents ->
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Cost:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatCost cents))
+
+ Lucid.div_ [Lucid.class_ "metric-row"] <| do
+ Lucid.span_ [Lucid.class_ "metric-label"] "Timestamp:"
+ Lucid.span_ [Lucid.class_ "metric-value"] (renderRelativeTimestamp currentTime (TaskCore.activityTimestamp act))
+
+ formatDuration :: UTCTime -> UTCTime -> Text
+ formatDuration start end =
+ let diffSecs = floor (diffUTCTime end start) :: Int
+ mins = diffSecs `div` 60
+ secs = diffSecs `mod` 60
+ in if mins > 0
+ then tshow mins <> "m " <> tshow secs <> "s"
+ else tshow secs <> "s"
+
+ formatCost :: Int -> Text
+ formatCost cents =
+ let dollars = fromIntegral cents / 100.0 :: Double
+ in "$" <> Text.pack (showFFloat (Just 2) dollars "")
+
+instance Lucid.ToHtml DescriptionViewPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (DescriptionViewPartial tid desc isEpic) =
+ Lucid.div_ [Lucid.id_ "description-block", Lucid.class_ "description-block"] <| do
+ Lucid.div_ [Lucid.class_ "description-header"] <| do
+ Lucid.h3_ (if isEpic then "Design" else "Description")
+ Lucid.a_
+ [ Lucid.href_ "#",
+ Lucid.class_ "edit-link",
+ Lucid.makeAttribute "hx-get" ("/tasks/" <> tid <> "/description/edit"),
+ Lucid.makeAttribute "hx-target" "#description-block",
+ Lucid.makeAttribute "hx-swap" "outerHTML"
+ ]
+ "Edit"
+ if Text.null desc
+ then Lucid.p_ [Lucid.class_ "empty-msg"] (if isEpic then "No design document yet." else "No description yet.")
+ else Lucid.div_ [Lucid.class_ "markdown-content"] (renderMarkdown desc)
+
+instance Lucid.ToHtml DescriptionEditPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (DescriptionEditPartial tid desc isEpic) =
+ Lucid.div_ [Lucid.id_ "description-block", Lucid.class_ "description-block editing"] <| do
+ Lucid.div_ [Lucid.class_ "description-header"] <| do
+ Lucid.h3_ (if isEpic then "Design" else "Description")
+ Lucid.button_
+ [ Lucid.type_ "button",
+ Lucid.class_ "cancel-link",
+ Lucid.makeAttribute "hx-get" ("/tasks/" <> tid <> "/description/view"),
+ Lucid.makeAttribute "hx-target" "#description-block",
+ Lucid.makeAttribute "hx-swap" "outerHTML",
+ Lucid.makeAttribute "hx-confirm" "Discard changes?"
+ ]
+ "Cancel"
+ Lucid.form_
+ [ Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/description"),
+ Lucid.makeAttribute "hx-target" "#description-block",
+ Lucid.makeAttribute "hx-swap" "outerHTML"
+ ]
+ <| do
+ Lucid.textarea_
+ [ Lucid.name_ "description",
+ Lucid.class_ "description-textarea",
+ Lucid.rows_ (if isEpic then "15" else "10"),
+ Lucid.placeholder_ (if isEpic then "Enter design in Markdown..." else "Enter description...")
+ ]
+ (Lucid.toHtml desc)
+ Lucid.div_ [Lucid.class_ "form-actions"] <| do
+ Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "btn btn-primary"] "Save"
+
+instance Lucid.ToHtml AgentEventsPartial where
+ toHtmlRaw = Lucid.toHtml
+ toHtml (AgentEventsPartial events isInProgress now) = do
+ Lucid.h3_ <| do
+ Lucid.toHtml ("Timeline (" <> tshow (length events) <> ")")
+ when isInProgress <| Lucid.span_ [Lucid.class_ "timeline-live"] " LIVE"
+ if null events
+ then Lucid.p_ [Lucid.class_ "empty-msg"] "No activity yet."
+ else do
+ Lucid.div_ [Lucid.class_ "timeline-events"] <| do
+ traverse_ (renderTimelineEvent now) events
+ timelineScrollScript
diff --git a/Omni/Jr/Web/Types.hs b/Omni/Jr/Web/Types.hs
new file mode 100644
index 0000000..c463bfa
--- /dev/null
+++ b/Omni/Jr/Web/Types.hs
@@ -0,0 +1,345 @@
+{-# LANGUAGE DataKinds #-}
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE NoImplicitPrelude #-}
+
+-- : dep servant-server
+-- : dep lucid
+-- : dep http-api-data
+-- : dep aeson
+module Omni.Jr.Web.Types
+ ( TaskFilters (..),
+ TimeRange (..),
+ SortOrder (..),
+ parseSortOrder,
+ sortOrderToParam,
+ sortOrderLabel,
+ sortTasks,
+ parseTimeRange,
+ timeRangeToParam,
+ getTimeRangeStart,
+ startOfDay,
+ startOfWeek,
+ addDays,
+ fromGregorian,
+ daysSinceEpoch,
+ startOfMonth,
+ computeMetricsFromActivities,
+ HomePage (..),
+ ReadyQueuePage (..),
+ BlockedPage (..),
+ InterventionPage (..),
+ TaskListPage (..),
+ TaskDetailPage (..),
+ GitCommit (..),
+ TaskReviewPage (..),
+ ReviewInfo (..),
+ TaskDiffPage (..),
+ StatsPage (..),
+ KBPage (..),
+ FactDetailPage (..),
+ EpicsPage (..),
+ RecentActivityNewPartial (..),
+ RecentActivityMorePartial (..),
+ ReadyCountPartial (..),
+ StatusBadgePartial (..),
+ PriorityBadgePartial (..),
+ TaskListPartial (..),
+ TaskMetricsPartial (..),
+ AgentEventsPartial (..),
+ DescriptionViewPartial (..),
+ DescriptionEditPartial (..),
+ FactEditForm (..),
+ FactCreateForm (..),
+ RejectForm (..),
+ StatusForm (..),
+ PriorityForm (..),
+ DescriptionForm (..),
+ NotesForm (..),
+ CommentForm (..),
+ Breadcrumb (..),
+ Breadcrumbs,
+ CSS,
+ SSE,
+ )
+where
+
+import Alpha
+import qualified Data.ByteString as BS
+import qualified Data.ByteString.Lazy as LBS
+import qualified Data.List as List
+import qualified Data.Text as Text
+import qualified Data.Text.Lazy as LazyText
+import Data.Time (Day, UTCTime (..), diffUTCTime, toGregorian)
+import Data.Time.Calendar (DayOfWeek (..))
+import Data.Time.Calendar.WeekDate (toWeekCalendar)
+import qualified Lucid
+import qualified Omni.Task.Core as TaskCore
+import Servant (Accept (..), MimeRender (..))
+import Web.FormUrlEncoded (FromForm (..), lookupUnique, parseUnique)
+
+data TaskFilters = TaskFilters
+ { filterStatus :: Maybe TaskCore.Status,
+ filterPriority :: Maybe TaskCore.Priority,
+ filterNamespace :: Maybe Text,
+ filterType :: Maybe TaskCore.TaskType
+ }
+ deriving (Show, Eq)
+
+data TimeRange = Today | Week | Month | AllTime
+ deriving (Show, Eq)
+
+data SortOrder
+ = SortNewest
+ | SortOldest
+ | SortUpdated
+ | SortPriorityHigh
+ | SortPriorityLow
+ deriving (Show, Eq)
+
+parseSortOrder :: Maybe Text -> SortOrder
+parseSortOrder (Just "oldest") = SortOldest
+parseSortOrder (Just "updated") = SortUpdated
+parseSortOrder (Just "priority-high") = SortPriorityHigh
+parseSortOrder (Just "priority-low") = SortPriorityLow
+parseSortOrder _ = SortNewest
+
+sortOrderToParam :: SortOrder -> Text
+sortOrderToParam SortNewest = "newest"
+sortOrderToParam SortOldest = "oldest"
+sortOrderToParam SortUpdated = "updated"
+sortOrderToParam SortPriorityHigh = "priority-high"
+sortOrderToParam SortPriorityLow = "priority-low"
+
+sortOrderLabel :: SortOrder -> Text
+sortOrderLabel SortNewest = "Newest First"
+sortOrderLabel SortOldest = "Oldest First"
+sortOrderLabel SortUpdated = "Recently Updated"
+sortOrderLabel SortPriorityHigh = "Priority (High to Low)"
+sortOrderLabel SortPriorityLow = "Priority (Low to High)"
+
+sortTasks :: SortOrder -> [TaskCore.Task] -> [TaskCore.Task]
+sortTasks SortNewest = List.sortBy (comparing (Down <. TaskCore.taskCreatedAt))
+sortTasks SortOldest = List.sortBy (comparing TaskCore.taskCreatedAt)
+sortTasks SortUpdated = List.sortBy (comparing (Down <. TaskCore.taskUpdatedAt))
+sortTasks SortPriorityHigh = List.sortBy (comparing TaskCore.taskPriority)
+sortTasks SortPriorityLow = List.sortBy (comparing (Down <. TaskCore.taskPriority))
+
+parseTimeRange :: Maybe Text -> TimeRange
+parseTimeRange (Just "today") = Today
+parseTimeRange (Just "week") = Week
+parseTimeRange (Just "month") = Month
+parseTimeRange _ = AllTime
+
+timeRangeToParam :: TimeRange -> Text
+timeRangeToParam Today = "today"
+timeRangeToParam Week = "week"
+timeRangeToParam Month = "month"
+timeRangeToParam AllTime = "all"
+
+getTimeRangeStart :: TimeRange -> UTCTime -> Maybe UTCTime
+getTimeRangeStart AllTime _ = Nothing
+getTimeRangeStart Today now = Just (startOfDay now)
+getTimeRangeStart Week now = Just (startOfWeek now)
+getTimeRangeStart Month now = Just (startOfMonth now)
+
+startOfDay :: UTCTime -> UTCTime
+startOfDay t = UTCTime (utctDay t) 0
+
+startOfWeek :: UTCTime -> UTCTime
+startOfWeek t =
+ let day = utctDay t
+ (_, _, dow) = toWeekCalendar day
+ daysBack = dow - 1
+ in UTCTime (addDays (negate (toInteger daysBack)) day) 0
+
+addDays :: Integer -> Day -> Day
+addDays n d =
+ let (y, m, dayNum) = toGregorian d
+ in fromGregorian y m (dayNum + fromInteger n)
+
+fromGregorian :: Integer -> Int -> Int -> Day
+fromGregorian y m d = toEnum (fromInteger (daysSinceEpoch y m d))
+
+daysSinceEpoch :: Integer -> Int -> Int -> Integer
+daysSinceEpoch y m d =
+ let a = (14 - m) `div` 12
+ y' = y + 4800 - toInteger a
+ m' = m + 12 * a - 3
+ jdn = d + (153 * m' + 2) `div` 5 + 365 * fromInteger y' + fromInteger y' `div` 4 - fromInteger y' `div` 100 + fromInteger y' `div` 400 - 32045
+ in toInteger jdn - 2440588
+
+startOfMonth :: UTCTime -> UTCTime
+startOfMonth t =
+ let day = utctDay t
+ (y, m, _) = toGregorian day
+ in UTCTime (fromGregorian y m 1) 0
+
+computeMetricsFromActivities :: [TaskCore.Task] -> [TaskCore.TaskActivity] -> TaskCore.AggregatedMetrics
+computeMetricsFromActivities tasks activities =
+ let completedCount = length [t | t <- tasks, TaskCore.taskStatus t == TaskCore.Done]
+ totalCost = sum [c | act <- activities, Just c <- [TaskCore.activityCostCents act]]
+ totalTokens = sum [t | act <- activities, Just t <- [TaskCore.activityTokensUsed act]]
+ totalDuration = sum [calcDuration act | act <- activities]
+ in TaskCore.AggregatedMetrics
+ { TaskCore.aggTotalCostCents = totalCost,
+ TaskCore.aggTotalDurationSeconds = totalDuration,
+ TaskCore.aggCompletedTasks = completedCount,
+ TaskCore.aggTotalTokens = totalTokens
+ }
+ where
+ calcDuration act = case (TaskCore.activityStartedAt act, TaskCore.activityCompletedAt act) of
+ (Just start, Just end) -> floor (diffUTCTime end start)
+ _ -> 0
+
+data CSS
+
+instance Accept CSS where
+ contentType _ = "text/css"
+
+instance MimeRender CSS LazyText.Text where
+ mimeRender _ = LazyText.encodeUtf8
+
+data SSE
+
+instance Accept SSE where
+ contentType _ = "text/event-stream"
+
+instance MimeRender SSE BS.ByteString where
+ mimeRender _ = LBS.fromStrict
+
+data HomePage = HomePage TaskCore.TaskStats [TaskCore.Task] [TaskCore.Task] Bool TaskCore.AggregatedMetrics TimeRange UTCTime
+
+data ReadyQueuePage = ReadyQueuePage [TaskCore.Task] SortOrder UTCTime
+
+data BlockedPage = BlockedPage [(TaskCore.Task, Int)] SortOrder UTCTime
+
+data InterventionPage = InterventionPage TaskCore.HumanActionItems SortOrder UTCTime
+
+data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters SortOrder UTCTime
+
+data TaskDetailPage
+ = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) [GitCommit] (Maybe TaskCore.AggregatedMetrics) [TaskCore.StoredEvent] UTCTime
+ | TaskDetailNotFound Text
+
+data GitCommit = GitCommit
+ { commitHash :: Text,
+ commitShortHash :: Text,
+ commitSummary :: Text,
+ commitAuthor :: Text,
+ commitRelativeDate :: Text,
+ commitFilesChanged :: Int
+ }
+ deriving (Show, Eq)
+
+data TaskReviewPage
+ = ReviewPageFound TaskCore.Task ReviewInfo
+ | ReviewPageNotFound Text
+
+data ReviewInfo
+ = ReviewNoCommit
+ | ReviewMergeConflict Text [Text]
+ | ReviewReady Text Text
+
+data TaskDiffPage
+ = DiffPageFound Text Text Text
+ | DiffPageNotFound Text Text
+
+data StatsPage = StatsPage TaskCore.TaskStats (Maybe Text)
+
+newtype KBPage = KBPage [TaskCore.Fact]
+
+data FactDetailPage
+ = FactDetailFound TaskCore.Fact UTCTime
+ | FactDetailNotFound Int
+
+data FactEditForm = FactEditForm Text Text Text
+
+instance FromForm FactEditForm where
+ fromForm form = do
+ content <- parseUnique "content" form
+ let files = fromRight "" (lookupUnique "files" form)
+ let confidence = fromRight "0.8" (lookupUnique "confidence" form)
+ Right (FactEditForm content files confidence)
+
+data FactCreateForm = FactCreateForm Text Text Text Text
+
+instance FromForm FactCreateForm where
+ fromForm form = do
+ project <- parseUnique "project" form
+ content <- parseUnique "content" form
+ let files = fromRight "" (lookupUnique "files" form)
+ let confidence = fromRight "0.8" (lookupUnique "confidence" form)
+ Right (FactCreateForm project content files confidence)
+
+data EpicsPage = EpicsPage [TaskCore.Task] [TaskCore.Task] SortOrder
+
+data RecentActivityNewPartial = RecentActivityNewPartial [TaskCore.Task] (Maybe Int)
+
+data RecentActivityMorePartial = RecentActivityMorePartial [TaskCore.Task] Int Bool
+
+newtype ReadyCountPartial = ReadyCountPartial Int
+
+data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text
+
+data PriorityBadgePartial = PriorityBadgePartial TaskCore.Priority Text
+
+newtype TaskListPartial = TaskListPartial [TaskCore.Task]
+
+data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) UTCTime
+
+data AgentEventsPartial = AgentEventsPartial [TaskCore.StoredEvent] Bool UTCTime
+
+data DescriptionViewPartial = DescriptionViewPartial Text Text Bool
+
+data DescriptionEditPartial = DescriptionEditPartial Text Text Bool
+
+newtype RejectForm = RejectForm (Maybe Text)
+
+instance FromForm RejectForm where
+ fromForm form = Right (RejectForm (either (const Nothing) Just (lookupUnique "notes" form)))
+
+newtype StatusForm = StatusForm TaskCore.Status
+
+instance FromForm StatusForm where
+ fromForm form = do
+ statusText <- parseUnique "status" form
+ case readMaybe (Text.unpack statusText) of
+ Just s -> Right (StatusForm s)
+ Nothing -> Left "Invalid status"
+
+newtype PriorityForm = PriorityForm TaskCore.Priority
+
+instance FromForm PriorityForm where
+ fromForm form = do
+ priorityText <- parseUnique "priority" form
+ case readMaybe (Text.unpack priorityText) of
+ Just p -> Right (PriorityForm p)
+ Nothing -> Left "Invalid priority"
+
+newtype DescriptionForm = DescriptionForm Text
+
+instance FromForm DescriptionForm where
+ fromForm form = do
+ desc <- parseUnique "description" form
+ Right (DescriptionForm desc)
+
+newtype NotesForm = NotesForm Text
+
+instance FromForm NotesForm where
+ fromForm form = do
+ notes <- parseUnique "notes" form
+ Right (NotesForm notes)
+
+newtype CommentForm = CommentForm Text
+
+instance FromForm CommentForm where
+ fromForm form = do
+ commentText <- parseUnique "comment" form
+ Right (CommentForm commentText)
+
+data Breadcrumb = Breadcrumb
+ { breadcrumbLabel :: Text,
+ breadcrumbUrl :: Maybe Text
+ }
+
+type Breadcrumbs = [Breadcrumb]