summaryrefslogtreecommitdiff
path: root/Omni/Jr
diff options
context:
space:
mode:
authorBen Sima <ben@bensima.com>2025-12-01 21:38:17 -0500
committerBen Sima <ben@bensima.com>2025-12-01 21:38:17 -0500
commit7da1c7e717af3cadf927b5f6efb253f0d10423a8 (patch)
treee6d52326215026a5cc1002a06f68cbd6281b1b6f /Omni/Jr
parent437a204017c7b357c3fc4dcc8c23151a288d1a8e (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.hs157
-rw-r--r--Omni/Jr/Web/Handlers.hs7
-rw-r--r--Omni/Jr/Web/Pages.hs9
-rw-r--r--Omni/Jr/Web/Partials.hs7
-rw-r--r--Omni/Jr/Web/Style.hs48
-rw-r--r--Omni/Jr/Web/Types.hs15
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