← Back to task

Commit a32a0210

commit a32a0210d17ed78ef653ee07d384041dbd10e5d2
Author: Ben Sima <ben@bensima.com>
Date:   Thu Nov 27 10:37:19 2025

    Add multiline description editing in web UI
    
    The implementation is complete and passing. Here's a summary of
    what was
    
    **Changes made:**
    
    1. **Added POST endpoint** for `/tasks/:id/description` - updates
    the ta
    
    2. **Added `DescriptionForm` type** with `FromForm` instance to
    parse th
    
    3. **Added `taskDescriptionHandler`** that:
       - Receives the description text from the form - Treats
       empty/whitespace-only descriptions as `Nothing` - Uses
       `TaskCore.editTask` to update the task description
    
    4. **Updated epic detail page** to include:
       - A collapsible "Edit Design" section using `<details>` element -
       A multiline `<textarea>` pre-populated with the current description
       - A "Save Design" button that submits to the new endpoint - Shows
       "No design document yet." when there's no description
    
    5. **Added CSS styles** for the new elements:
       - `.edit-description` - styling for the collapsible edit section -
       `.description-textarea` - monospace font textarea for markdown
       edit - `.form-actions` - container for the submit button - Dark
       mode support for all new elements
    
    Task-Id: t-150.2

diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 49c9ad66..6c30be30 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -58,6 +58,7 @@ type API =
       :> Get '[Lucid.HTML] TaskListPage
     :<|> "tasks" :> Capture "id" Text :> Get '[Lucid.HTML] TaskDetailPage
     :<|> "tasks" :> Capture "id" Text :> "status" :> ReqBody '[FormUrlEncoded] StatusForm :> PostRedirect
+    :<|> "tasks" :> Capture "id" Text :> "description" :> ReqBody '[FormUrlEncoded] DescriptionForm :> PostRedirect
     :<|> "tasks" :> Capture "id" Text :> "review" :> Get '[Lucid.HTML] TaskReviewPage
     :<|> "tasks" :> Capture "id" Text :> "accept" :> PostRedirect
     :<|> "tasks" :> Capture "id" Text :> "reject" :> ReqBody '[FormUrlEncoded] RejectForm :> PostRedirect
@@ -109,6 +110,13 @@ instance FromForm StatusForm where
       Just s -> Right (StatusForm s)
       Nothing -> Left "Invalid status"
 
+newtype DescriptionForm = DescriptionForm Text
+
+instance FromForm DescriptionForm where
+  fromForm form = do
+    desc <- parseUnique "description" form
+    Right (DescriptionForm desc)
+
 pageHead :: (Monad m) => Text -> Lucid.HtmlT m ()
 pageHead title =
   Lucid.head_ <| do
@@ -377,15 +385,30 @@ instance Lucid.ToHtml TaskDetailPage where
                 Lucid.ul_ [Lucid.class_ "dep-list"] <| do
                   traverse_ renderDependency deps
 
-            case TaskCore.taskDescription task of
-              Nothing -> pure ()
-              Just desc ->
+            case TaskCore.taskType task of
+              TaskCore.Epic -> do
                 Lucid.div_ [Lucid.class_ "detail-section"] <| do
-                  case TaskCore.taskType task of
-                    TaskCore.Epic -> do
-                      Lucid.h3_ "Design"
-                      Lucid.div_ [Lucid.class_ "markdown-content"] (renderMarkdown desc)
-                    _ -> do
+                  Lucid.h3_ "Design"
+                  case TaskCore.taskDescription task of
+                    Nothing -> Lucid.p_ [Lucid.class_ "empty-msg"] "No design document yet."
+                    Just desc -> Lucid.div_ [Lucid.class_ "markdown-content"] (renderMarkdown desc)
+                  Lucid.details_ [Lucid.class_ "edit-description"] <| do
+                    Lucid.summary_ "Edit Design"
+                    Lucid.form_ [Lucid.method_ "POST", Lucid.action_ ("/tasks/" <> TaskCore.taskId task <> "/description")] <| do
+                      Lucid.textarea_
+                        [ Lucid.name_ "description",
+                          Lucid.class_ "description-textarea",
+                          Lucid.rows_ "15",
+                          Lucid.placeholder_ "Enter design in Markdown format..."
+                        ]
+                        (Lucid.toHtml (fromMaybe "" (TaskCore.taskDescription task)))
+                      Lucid.div_ [Lucid.class_ "form-actions"] <| do
+                        Lucid.button_ [Lucid.type_ "submit", Lucid.class_ "submit-btn"] "Save Design"
+              _ ->
+                case TaskCore.taskDescription task of
+                  Nothing -> pure ()
+                  Just desc ->
+                    Lucid.div_ [Lucid.class_ "detail-section"] <| do
                       Lucid.h3_ "Description"
                       Lucid.pre_ [Lucid.class_ "description"] (Lucid.toHtml desc)
 
@@ -790,6 +813,7 @@ server =
     :<|> taskListHandler
     :<|> taskDetailHandler
     :<|> taskStatusHandler
+    :<|> taskDescriptionHandler
     :<|> taskReviewHandler
     :<|> taskAcceptHandler
     :<|> taskRejectHandler
@@ -884,6 +908,12 @@ server =
       liftIO <| TaskCore.updateTaskStatus tid newStatus []
       pure <| addHeader ("/tasks/" <> tid) NoContent
 
+    taskDescriptionHandler :: Text -> DescriptionForm -> Servant.Handler (Headers '[Header "Location" Text] NoContent)
+    taskDescriptionHandler tid (DescriptionForm desc) = do
+      let descMaybe = if Text.null (Text.strip desc) then Nothing else Just desc
+      _ <- liftIO <| TaskCore.editTask tid (\t -> t {TaskCore.taskDescription = descMaybe})
+      pure <| addHeader ("/tasks/" <> tid) NoContent
+
     taskReviewHandler :: Text -> Servant.Handler TaskReviewPage
     taskReviewHandler tid = do
       tasks <- liftIO TaskCore.loadTasks
diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs
index d544e25b..dbe1daad 100644
--- a/Omni/Jr/Web/Style.hs
+++ b/Omni/Jr/Web/Style.hs
@@ -473,6 +473,29 @@ formStyles = do
     borderRadius (px 2) (px 2) (px 2) (px 2)
     fontSize (px 13)
     Stylesheet.key "resize" ("vertical" :: Text)
+  ".edit-description" ? do
+    marginTop (px 8)
+    padding (px 8) (px 0) (px 0) (px 0)
+    borderTop (px 1) solid "#e5e7eb"
+  (".edit-description" |> "summary") ? do
+    cursor pointer
+    color "#0066cc"
+    fontSize (px 13)
+    fontWeight (weight 500)
+  (".edit-description" |> "summary") # hover ? textDecoration underline
+  ".description-textarea" ? do
+    width (pct 100)
+    minHeight (px 250)
+    padding (px 8) (px 10) (px 8) (px 10)
+    border (px 1) solid "#d1d5db"
+    borderRadius (px 2) (px 2) (px 2) (px 2)
+    fontSize (px 13)
+    fontFamily ["SF Mono", "Monaco", "Consolas", "monospace"] [monospace]
+    lineHeight (em 1.5)
+    Stylesheet.key "resize" ("vertical" :: Text)
+    marginTop (px 8)
+  ".form-actions" ? do
+    marginTop (px 8)
 
 activityTimelineStyles :: Css
 activityTimelineStyles = do
@@ -733,6 +756,12 @@ darkModeStyles =
     ".md-inline-code" ? do
       backgroundColor "#374151"
       color "#f3f4f6"
+    ".edit-description" ? borderTopColor "#374151"
+    (".edit-description" |> "summary") ? color "#60a5fa"
+    ".description-textarea" ? do
+      backgroundColor "#374151"
+      borderColor "#4b5563"
+      color "#f3f4f6"
 
 prefersDark :: Stylesheet.Feature
 prefersDark =