diff options
| author | Ben Sima <ben@bensima.com> | 2025-12-01 21:38:17 -0500 |
|---|---|---|
| committer | Ben Sima <ben@bensima.com> | 2025-12-01 21:38:17 -0500 |
| commit | 7da1c7e717af3cadf927b5f6efb253f0d10423a8 (patch) | |
| tree | e6d52326215026a5cc1002a06f68cbd6281b1b6f /Omni/Jr | |
| parent | 437a204017c7b357c3fc4dcc8c23151a288d1a8e (diff) | |
Make complexity badge editable on task detail page
- Add ComplexityForm and ComplexityBadgePartial types
- Add /tasks/:id/complexity POST endpoint
- Add complexityBadgeWithForm component with dropdown
- Add complexity dropdown JS for keyboard navigation
- Add CSS styles for complexity dropdown
- Always show complexity badge (Set Complexity if none)
Task-Id: t-219
Diffstat (limited to 'Omni/Jr')
| -rw-r--r-- | Omni/Jr/Web/Components.hs | 157 | ||||
| -rw-r--r-- | Omni/Jr/Web/Handlers.hs | 7 | ||||
| -rw-r--r-- | Omni/Jr/Web/Pages.hs | 9 | ||||
| -rw-r--r-- | Omni/Jr/Web/Partials.hs | 7 | ||||
| -rw-r--r-- | Omni/Jr/Web/Style.hs | 48 | ||||
| -rw-r--r-- | Omni/Jr/Web/Types.hs | 15 |
6 files changed, 237 insertions, 6 deletions
diff --git a/Omni/Jr/Web/Components.hs b/Omni/Jr/Web/Components.hs index 7dd13c7..4429f75 100644 --- a/Omni/Jr/Web/Components.hs +++ b/Omni/Jr/Web/Components.hs @@ -24,6 +24,7 @@ module Omni.Jr.Web.Components navbarDropdownJs, statusDropdownJs, priorityDropdownJs, + complexityDropdownJs, liveToggleJs, -- * Breadcrumbs @@ -44,6 +45,10 @@ module Omni.Jr.Web.Components clickablePriorityBadge, priorityDropdownOptions, priorityOption, + complexityBadgeWithForm, + clickableComplexityBadge, + complexityDropdownOptions, + complexityOption, -- * Sorting sortDropdown, @@ -188,6 +193,7 @@ pageHead title = ("" :: Text) Lucid.script_ [] statusDropdownJs Lucid.script_ [] priorityDropdownJs + Lucid.script_ [] complexityDropdownJs Lucid.script_ [] navbarDropdownJs Lucid.script_ [] liveToggleJs @@ -362,6 +368,77 @@ priorityDropdownJs = "});" ] +complexityDropdownJs :: Text +complexityDropdownJs = + Text.unlines + [ "function toggleComplexityDropdown(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 closeComplexityDropdown(container) {", + " container.classList.remove('open');", + " var badge = container.querySelector('[role=\"button\"]');", + " if (badge) {", + " badge.setAttribute('aria-expanded', 'false');", + " badge.focus();", + " }", + "}", + "", + "function handleComplexityKeydown(event, el) {", + " if (event.key === 'Enter' || event.key === ' ') {", + " event.preventDefault();", + " toggleComplexityDropdown(el);", + " } else if (event.key === 'Escape') {", + " closeComplexityDropdown(el.parentElement);", + " } else if (event.key === 'ArrowDown') {", + " event.preventDefault();", + " var container = el.parentElement;", + " if (!container.classList.contains('open')) {", + " toggleComplexityDropdown(el);", + " } else {", + " var firstItem = container.querySelector('[role=\"menuitem\"]');", + " if (firstItem) firstItem.focus();", + " }", + " }", + "}", + "", + "function handleComplexityMenuItemKeydown(event) {", + " var container = event.target.closest('.complexity-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();", + " closeComplexityDropdown(container);", + " } else if (event.key === 'Tab') {", + " closeComplexityDropdown(container);", + " }", + "}", + "", + "document.addEventListener('click', function(e) {", + " var dropdowns = document.querySelectorAll('.complexity-badge-dropdown.open');", + " dropdowns.forEach(function(d) {", + " if (!d.contains(e.target)) {", + " closeComplexityDropdown(d);", + " }", + " });", + "});" + ] + liveToggleJs :: Text liveToggleJs = Text.unlines @@ -774,6 +851,86 @@ priorityOption opt currentPriority tid = ] (Lucid.toHtml label) +-- * Complexity badge with form + +complexityBadgeWithForm :: (Monad m) => Maybe Int -> Text -> Lucid.HtmlT m () +complexityBadgeWithForm complexity tid = + Lucid.div_ + [ Lucid.id_ "complexity-badge-container", + Lucid.class_ "complexity-badge-dropdown" + ] + <| do + clickableComplexityBadge complexity tid + complexityDropdownOptions complexity tid + +clickableComplexityBadge :: (Monad m) => Maybe Int -> Text -> Lucid.HtmlT m () +clickableComplexityBadge complexity _tid = + let (cls, label) = case complexity of + Nothing -> ("badge badge-complexity-none complexity-badge-clickable", "Set Complexity" :: Text) + Just 1 -> ("badge badge-complexity-1 complexity-badge-clickable", "ℂ 1") + Just 2 -> ("badge badge-complexity-2 complexity-badge-clickable", "ℂ 2") + Just 3 -> ("badge badge-complexity-3 complexity-badge-clickable", "ℂ 3") + Just 4 -> ("badge badge-complexity-4 complexity-badge-clickable", "ℂ 4") + Just 5 -> ("badge badge-complexity-5 complexity-badge-clickable", "ℂ 5") + Just _ -> ("badge badge-complexity-none complexity-badge-clickable", "Invalid") + 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" "toggleComplexityDropdown(this)", + Lucid.makeAttribute "onkeydown" "handleComplexityKeydown(event, this)" + ] + <| do + Lucid.toHtml label + Lucid.span_ [Lucid.class_ "dropdown-arrow", Lucid.makeAttribute "aria-hidden" "true"] " ▾" + +complexityDropdownOptions :: (Monad m) => Maybe Int -> Text -> Lucid.HtmlT m () +complexityDropdownOptions currentComplexity tid = + Lucid.div_ + [ Lucid.class_ "complexity-dropdown-menu", + Lucid.role_ "menu", + Lucid.makeAttribute "aria-label" "Change task complexity" + ] + <| do + complexityOption Nothing currentComplexity tid + complexityOption (Just 1) currentComplexity tid + complexityOption (Just 2) currentComplexity tid + complexityOption (Just 3) currentComplexity tid + complexityOption (Just 4) currentComplexity tid + complexityOption (Just 5) currentComplexity tid + +complexityOption :: (Monad m) => Maybe Int -> Maybe Int -> Text -> Lucid.HtmlT m () +complexityOption opt currentComplexity tid = + let (cls, label, val) = case opt of + Nothing -> ("badge badge-complexity-none", "None" :: Text, "none" :: Text) + Just 1 -> ("badge badge-complexity-1", "ℂ 1", "1") + Just 2 -> ("badge badge-complexity-2", "ℂ 2", "2") + Just 3 -> ("badge badge-complexity-3", "ℂ 3", "3") + Just 4 -> ("badge badge-complexity-4", "ℂ 4", "4") + Just 5 -> ("badge badge-complexity-5", "ℂ 5", "5") + Just _ -> ("badge badge-complexity-none", "Invalid", "none") + isSelected = opt == currentComplexity + optClass = cls <> " complexity-dropdown-option" <> if isSelected then " selected" else "" + in Lucid.form_ + [ Lucid.class_ "complexity-option-form", + Lucid.role_ "none", + Lucid.makeAttribute "hx-post" ("/tasks/" <> tid <> "/complexity"), + Lucid.makeAttribute "hx-target" "#complexity-badge-container", + Lucid.makeAttribute "hx-swap" "outerHTML" + ] + <| do + Lucid.input_ [Lucid.type_ "hidden", Lucid.name_ "complexity", Lucid.value_ val] + Lucid.button_ + [ Lucid.type_ "submit", + Lucid.class_ optClass, + Lucid.role_ "menuitem", + Lucid.tabindex_ "-1", + Lucid.makeAttribute "onkeydown" "handleComplexityMenuItemKeydown(event)" + ] + (Lucid.toHtml label) + -- * Task rendering renderTaskCard :: (Monad m) => TaskCore.Task -> Lucid.HtmlT m () diff --git a/Omni/Jr/Web/Handlers.hs b/Omni/Jr/Web/Handlers.hs index 5b542dd..9dd5847 100644 --- a/Omni/Jr/Web/Handlers.hs +++ b/Omni/Jr/Web/Handlers.hs @@ -60,6 +60,7 @@ type API = :<|> "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 :> "complexity" :> ReqBody '[FormUrlEncoded] ComplexityForm :> Post '[Lucid.HTML] ComplexityBadgePartial :<|> "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 @@ -106,6 +107,7 @@ server = :<|> taskDetailHandler :<|> taskStatusHandler :<|> taskPriorityHandler + :<|> taskComplexityHandler :<|> descriptionViewHandler :<|> descriptionEditHandler :<|> descriptionPostHandler @@ -298,6 +300,11 @@ server = _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskPriority = newPriority}) pure (PriorityBadgePartial newPriority tid) + taskComplexityHandler :: Text -> ComplexityForm -> Servant.Handler ComplexityBadgePartial + taskComplexityHandler tid (ComplexityForm newComplexity) = do + _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskComplexity = newComplexity}) + pure (ComplexityBadgePartial newComplexity tid) + descriptionViewHandler :: Text -> Servant.Handler DescriptionViewPartial descriptionViewHandler tid = do tasks <- liftIO TaskCore.loadTasks diff --git a/Omni/Jr/Web/Pages.hs b/Omni/Jr/Web/Pages.hs index 2fbbc00..b3cc8ea 100644 --- a/Omni/Jr/Web/Pages.hs +++ b/Omni/Jr/Web/Pages.hs @@ -18,7 +18,7 @@ import qualified Lucid.Base as Lucid import Numeric (showFFloat) import Omni.Jr.Web.Components ( Breadcrumb (..), - complexityBadge, + complexityBadgeWithForm, metaSep, multiColorProgressBar, pageBody, @@ -567,11 +567,8 @@ instance Lucid.ToHtml TaskDetailPage where 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 + metaSep + complexityBadgeWithForm (TaskCore.taskComplexity task) (TaskCore.taskId task) case TaskCore.taskNamespace task of Nothing -> pure () Just ns -> do diff --git a/Omni/Jr/Web/Partials.hs b/Omni/Jr/Web/Partials.hs index 25a4d1e..2660441 100644 --- a/Omni/Jr/Web/Partials.hs +++ b/Omni/Jr/Web/Partials.hs @@ -18,6 +18,7 @@ import Numeric (showFFloat) import Omni.Jr.Web.Components ( aggregateCostMetrics, commentForm, + complexityBadgeWithForm, formatCostHeader, formatTokensHeader, metaSep, @@ -33,6 +34,7 @@ import Omni.Jr.Web.Components ) import Omni.Jr.Web.Types ( AgentEventsPartial (..), + ComplexityBadgePartial (..), DescriptionEditPartial (..), DescriptionViewPartial (..), PriorityBadgePartial (..), @@ -92,6 +94,11 @@ instance Lucid.ToHtml PriorityBadgePartial where toHtml (PriorityBadgePartial priority tid) = priorityBadgeWithForm priority tid +instance Lucid.ToHtml ComplexityBadgePartial where + toHtmlRaw = Lucid.toHtml + toHtml (ComplexityBadgePartial complexity tid) = + complexityBadgeWithForm complexity tid + instance Lucid.ToHtml TaskListPartial where toHtmlRaw = Lucid.toHtml toHtml (TaskListPartial tasks) = diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs index c385ac7..0f4b300 100644 --- a/Omni/Jr/Web/Style.hs +++ b/Omni/Jr/Web/Style.hs @@ -714,6 +714,54 @@ statusBadges = do ".badge-complexity-5" ? do backgroundColor "#fee2e2" color "#991b1b" + ".badge-complexity-none" ? do + backgroundColor "#f3f4f6" + color "#6b7280" + ".complexity-badge-dropdown" ? do + position relative + display inlineBlock + ".complexity-badge-clickable" ? do + cursor pointer + Stylesheet.key "user-select" ("none" :: Text) + ".complexity-badge-clickable" # hover ? do + opacity 0.85 + ".complexity-dropdown-menu" ? do + display none + position absolute + left (px 0) + top (pct 100) + marginTop (px 2) + backgroundColor white + borderRadius (px 4) (px 4) (px 4) (px 4) + Stylesheet.key "box-shadow" ("0 2px 8px rgba(0,0,0,0.15)" :: Text) + zIndex 100 + padding (px 4) (px 4) (px 4) (px 4) + minWidth (px 100) + ".complexity-badge-dropdown.open" |> ".complexity-dropdown-menu" ? do + display block + ".complexity-option-form" ? do + margin (px 0) (px 0) (px 0) (px 0) + padding (px 0) (px 0) (px 0) (px 0) + ".complexity-dropdown-option" ? do + display block + width (pct 100) + textAlign (alignSide sideLeft) + margin (px 2) (px 0) (px 2) (px 0) + border (px 0) none transparent + cursor pointer + transition "opacity" (ms 150) ease (sec 0) + ".complexity-dropdown-option" # hover ? do + opacity 0.7 + ".complexity-dropdown-option" # focus ? do + opacity 0.85 + Stylesheet.key "outline" ("2px solid #0066cc" :: Text) + Stylesheet.key "outline-offset" ("1px" :: Text) + ".complexity-dropdown-option.selected" ? do + Stylesheet.key "outline" ("2px solid #0066cc" :: Text) + Stylesheet.key "outline-offset" ("1px" :: Text) + ".complexity-badge-clickable" # focus ? do + Stylesheet.key "outline" ("2px solid #0066cc" :: Text) + Stylesheet.key "outline-offset" ("2px" :: Text) buttonStyles :: Css buttonStyles = do diff --git a/Omni/Jr/Web/Types.hs b/Omni/Jr/Web/Types.hs index 85ea0f0..93c8d85 100644 --- a/Omni/Jr/Web/Types.hs +++ b/Omni/Jr/Web/Types.hs @@ -43,6 +43,7 @@ module Omni.Jr.Web.Types ReadyCountPartial (..), StatusBadgePartial (..), PriorityBadgePartial (..), + ComplexityBadgePartial (..), TaskListPartial (..), TaskMetricsPartial (..), AgentEventsPartial (..), @@ -53,6 +54,7 @@ module Omni.Jr.Web.Types RejectForm (..), StatusForm (..), PriorityForm (..), + ComplexityForm (..), DescriptionForm (..), NotesForm (..), CommentForm (..), @@ -288,6 +290,8 @@ data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text data PriorityBadgePartial = PriorityBadgePartial TaskCore.Priority Text +data ComplexityBadgePartial = ComplexityBadgePartial (Maybe Int) Text + newtype TaskListPartial = TaskListPartial [TaskCore.Task] data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext) UTCTime @@ -321,6 +325,17 @@ instance FromForm PriorityForm where Just p -> Right (PriorityForm p) Nothing -> Left "Invalid priority" +newtype ComplexityForm = ComplexityForm (Maybe Int) + +instance FromForm ComplexityForm where + fromForm form = do + complexityText <- parseUnique "complexity" form + if complexityText == "none" + then Right (ComplexityForm Nothing) + else case readMaybe (Text.unpack complexityText) of + Just c | c >= 1 && c <= 5 -> Right (ComplexityForm (Just c)) + _ -> Left "Invalid complexity" + newtype DescriptionForm = DescriptionForm Text instance FromForm DescriptionForm where |
