← Back to task

Commit 83ff4b62

commit 83ff4b622be49762491dac216ab8df374b24cd74
Author: Ben Sima <ben@bensima.com>
Date:   Thu Nov 27 10:59:38 2025

    Display worker metrics on task detail page
    
    All tests pass. Let me summarize what was implemented:
    
    - Extended `TaskActivity` type with new fields:
      - `activityAmpThreadUrl` - Link to amp thread - `activityStartedAt` -
      Work start timestamp - `activityCompletedAt` - Work completion
      timestamp - `activityCostCents` - API cost in cents -
      `activityTokensUsed` - Token usage count
    
    - Updated `SQL.FromRow` and `SQL.ToRow` instances for the new fields -
    Updated schema to include new columns in `task_activity` table - Added
    `logActivityWithMetrics` function to log activities with all met -
    Added `updateActivityMetrics` function to update metrics on existing
    a - Added `getLatestRunningActivity` helper function
    
    - Captures execution timing (start/end timestamps) - Retrieves amp
    thread URL from `AgentLog.getStatus` - Converts credits to cents
    and logs to activity record - Uses `logActivityWithMetrics` and
    `updateActivityMetrics` for tracking
    
    - Added `getStatus` function to retrieve current status (thread
    URL, cre
    
    - Added `TaskMetricsPartial` type for HTMX auto-refresh - Extended
    `TaskDetailPage` to include `RetryContext` - Added Execution Details
    section on task detail page showing:
      - Amp Thread URL (clickable link) - Duration (formatted as "Xm Ys")
      - Cost (formatted as "$X.XX") - Retry Attempt count (if applicable)
      - Last Activity timestamp
    - Added `/partials/task/:id/metrics` endpoint for HTMX auto-refresh
    - Auto-refresh enabled while task is InProgress (every 5s) - Added
    `renderExecutionDetails` helper function
    
    - Added `executionDetailsStyles` for metric rows and execution section
    - Added dark mode support for execution details section
    
    Task-Id: t-148.4

diff --git a/Omni/Agent/Log.hs b/Omni/Agent/Log.hs
index b1020c5a..55bc1e25 100644
--- a/Omni/Agent/Log.hs
+++ b/Omni/Agent/Log.hs
@@ -72,6 +72,10 @@ update f = do
   modifyIORef' currentStatus f
   render
 
+-- | Get the current status
+getStatus :: IO Status
+getStatus = readIORef currentStatus
+
 -- | Set the activity message
 updateActivity :: Text -> IO ()
 updateActivity msg = update (\s -> s {statusActivity = msg})
diff --git a/Omni/Agent/Worker.hs b/Omni/Agent/Worker.hs
index 2557d70f..eef31f47 100644
--- a/Omni/Agent/Worker.hs
+++ b/Omni/Agent/Worker.hs
@@ -10,6 +10,7 @@ import qualified Data.ByteString.Lazy as BSL
 import qualified Data.Text as Text
 import qualified Data.Text.Encoding as TE
 import qualified Data.Text.IO as TIO
+import qualified Data.Time
 import qualified Omni.Agent.Core as Core
 import qualified Omni.Agent.Log as AgentLog
 import qualified Omni.Task.Core as TaskCore
@@ -83,12 +84,22 @@ processTask worker task = do
   TaskCore.updateTaskStatus tid TaskCore.InProgress []
   say "[worker] Status -> InProgress"
 
-  -- Run Amp
+  -- Run Amp with timing
   say "[worker] Starting amp..."
-  TaskCore.logActivity tid TaskCore.Running Nothing
+  startTime <- Data.Time.getCurrentTime
+  activityId <- TaskCore.logActivityWithMetrics tid TaskCore.Running Nothing Nothing (Just startTime) Nothing Nothing Nothing
   (exitCode, output) <- runAmp repo task
+  endTime <- Data.Time.getCurrentTime
   say ("[worker] Amp exited with: " <> tshow exitCode)
 
+  -- Capture metrics from agent log (thread URL, credits)
+  status <- AgentLog.getStatus
+  let threadUrl = ("https://ampcode.com/threads/" <>) </ AgentLog.statusThread status
+  let costCents = Just <| floor (AgentLog.statusCredits status * 100)
+
+  -- Update the activity record with metrics
+  TaskCore.updateActivityMetrics activityId threadUrl (Just endTime) costCents Nothing
+
   case exitCode of
     Exit.ExitSuccess -> do
       TaskCore.logActivity tid TaskCore.Reviewing Nothing
diff --git a/Omni/Jr/Web.hs b/Omni/Jr/Web.hs
index 3c24d71c..5fc8126f 100644
--- a/Omni/Jr/Web.hs
+++ b/Omni/Jr/Web.hs
@@ -21,10 +21,11 @@ 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 (UTCTime, defaultTimeLocale, formatTime)
+import Data.Time (UTCTime, defaultTimeLocale, diffUTCTime, formatTime)
 import qualified Lucid
 import qualified Lucid.Base as Lucid
 import qualified Network.Wai.Handler.Warp as Warp
+import Numeric (showFFloat)
 import qualified Omni.Jr.Web.Style as Style
 import qualified Omni.Task.Core as TaskCore
 import Servant
@@ -71,6 +72,7 @@ type API =
       :> QueryParam "priority" Text
       :> QueryParam "namespace" Text
       :> Get '[Lucid.HTML] TaskListPartial
+    :<|> "partials" :> "task" :> Capture "id" Text :> "metrics" :> Get '[Lucid.HTML] TaskMetricsPartial
 
 data CSS
 
@@ -91,7 +93,7 @@ newtype InterventionPage = InterventionPage [TaskCore.Task]
 data TaskListPage = TaskListPage [TaskCore.Task] TaskFilters
 
 data TaskDetailPage
-  = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity]
+  = TaskDetailFound TaskCore.Task [TaskCore.Task] [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext)
   | TaskDetailNotFound Text
 
 data TaskReviewPage
@@ -113,6 +115,8 @@ data StatusBadgePartial = StatusBadgePartial TaskCore.Status Text
 
 newtype TaskListPartial = TaskListPartial [TaskCore.Task]
 
+data TaskMetricsPartial = TaskMetricsPartial Text [TaskCore.TaskActivity] (Maybe TaskCore.RetryContext)
+
 newtype RejectForm = RejectForm (Maybe Text)
 
 instance FromForm RejectForm where
@@ -402,7 +406,7 @@ instance Lucid.ToHtml TaskDetailPage where
             "The task "
             Lucid.code_ (Lucid.toHtml tid)
             " could not be found."
-  toHtml (TaskDetailFound task allTasks activities) =
+  toHtml (TaskDetailFound task allTasks activities maybeRetry) =
     Lucid.doctypehtml_ <| do
       pageHead (TaskCore.taskId task <> " - Jr")
       Lucid.body_ <| do
@@ -492,6 +496,21 @@ instance Lucid.ToHtml TaskDetailPage where
                 Lucid.ul_ [Lucid.class_ "child-list"] <| do
                   traverse_ renderChild children
 
+          let hasRunningActivity = any (\a -> TaskCore.activityStage a == TaskCore.Running) activities
+          when hasRunningActivity <| do
+            let isInProgress = TaskCore.taskStatus task == TaskCore.InProgress
+                htmxAttrs =
+                  [ Lucid.makeAttribute "hx-get" ("/partials/task/" <> TaskCore.taskId task <> "/metrics"),
+                    Lucid.makeAttribute "hx-trigger" "every 5s",
+                    Lucid.makeAttribute "hx-swap" "innerHTML"
+                  ]
+                sectionAttrs =
+                  [Lucid.class_ "execution-section", Lucid.id_ "execution-details"]
+                    <> [attr | isInProgress, attr <- htmxAttrs]
+            Lucid.div_ sectionAttrs <| do
+              Lucid.h3_ "Execution Details"
+              renderExecutionDetails (TaskCore.taskId task) activities maybeRetry
+
           when (TaskCore.taskStatus task == TaskCore.InProgress && not (null activities)) <| do
             Lucid.div_ [Lucid.class_ "activity-section"] <| do
               Lucid.h3_ "Activity Timeline"
@@ -566,6 +585,64 @@ instance Lucid.ToHtml TaskDetailPage where
       formatTimestamp :: UTCTime -> Text
       formatTimestamp = Text.pack <. formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S"
 
+      renderExecutionDetails :: (Monad m) => Text -> [TaskCore.TaskActivity] -> Maybe TaskCore.RetryContext -> Lucid.HtmlT m ()
+      renderExecutionDetails _ acts retryCtx =
+        case findRunningAct acts of
+          Nothing -> Lucid.p_ [Lucid.class_ "empty-msg"] "No worker execution data available."
+          Just act ->
+            Lucid.div_ [Lucid.class_ "execution-details"] <| do
+              case TaskCore.activityAmpThreadUrl act of
+                Nothing -> pure ()
+                Just url ->
+                  Lucid.div_ [Lucid.class_ "metric-row"] <| do
+                    Lucid.span_ [Lucid.class_ "metric-label"] "Amp Thread:"
+                    Lucid.a_ [Lucid.href_ url, Lucid.target_ "_blank", Lucid.class_ "metric-value amp-link"] (Lucid.toHtml url)
+
+              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 (formatDur 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"] (Lucid.toHtml (formatTimestamp 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 (formatCostVal cents))
+
+              case retryCtx 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"))
+
+              Lucid.div_ [Lucid.class_ "metric-row"] <| do
+                Lucid.span_ [Lucid.class_ "metric-label"] "Last Activity:"
+                Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTimestamp (TaskCore.activityTimestamp act)))
+        where
+          findRunningAct = List.find (\a -> TaskCore.activityStage a == TaskCore.Running)
+
+          formatDur :: UTCTime -> UTCTime -> Text
+          formatDur 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"
+
+          formatCostVal :: Int -> Text
+          formatCostVal cents =
+            let dollars = fromIntegral cents / 100.0 :: Double
+             in "$" <> Text.pack (showFFloat (Just 2) dollars "")
+
 instance Lucid.ToHtml TaskReviewPage where
   toHtmlRaw = Lucid.toHtml
   toHtml (ReviewPageNotFound tid) =
@@ -772,6 +849,75 @@ instance Lucid.ToHtml TaskListPartial where
       then Lucid.p_ [Lucid.class_ "empty-msg"] "No tasks match the current filters."
       else Lucid.div_ [Lucid.class_ "task-list"] <| traverse_ renderTaskCard tasks
 
+instance Lucid.ToHtml TaskMetricsPartial where
+  toHtmlRaw = Lucid.toHtml
+  toHtml (TaskMetricsPartial _tid activities maybeRetry) =
+    Lucid.div_ [Lucid.class_ "execution-details"] <| do
+      case findRunningActivity activities of
+        Nothing -> Lucid.p_ [Lucid.class_ "empty-msg"] "No worker execution data available."
+        Just act -> do
+          case TaskCore.activityAmpThreadUrl act of
+            Nothing -> pure ()
+            Just url ->
+              Lucid.div_ [Lucid.class_ "metric-row"] <| do
+                Lucid.span_ [Lucid.class_ "metric-label"] "Amp Thread:"
+                Lucid.a_ [Lucid.href_ url, Lucid.target_ "_blank", Lucid.class_ "metric-value amp-link"] (Lucid.toHtml url)
+
+          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"] (Lucid.toHtml (formatTimestamp 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))
+
+          case TaskCore.activityTokensUsed act of
+            Nothing -> pure ()
+            Just tokens ->
+              Lucid.div_ [Lucid.class_ "metric-row"] <| do
+                Lucid.span_ [Lucid.class_ "metric-label"] "Tokens:"
+                Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (tshow tokens))
+
+          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"))
+
+          Lucid.div_ [Lucid.class_ "metric-row"] <| do
+            Lucid.span_ [Lucid.class_ "metric-label"] "Last Activity:"
+            Lucid.span_ [Lucid.class_ "metric-value"] (Lucid.toHtml (formatTimestamp (TaskCore.activityTimestamp act)))
+    where
+      findRunningActivity = List.find (\a -> TaskCore.activityStage a == TaskCore.Running)
+
+      formatTimestamp :: UTCTime -> Text
+      formatTimestamp = Text.pack <. formatTime defaultTimeLocale "%Y-%m-%d %H:%M:%S"
+
+      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 "")
+
 -- | Simple markdown renderer for epic descriptions
 -- Supports: headers (#, ##, ###), lists (- or *), code blocks (```), inline code (`)
 renderMarkdown :: (Monad m) => Text -> Lucid.HtmlT m ()
@@ -904,6 +1050,7 @@ server =
     :<|> recentActivityHandler
     :<|> readyCountHandler
     :<|> taskListPartialHandler
+    :<|> taskMetricsPartialHandler
   where
     styleHandler :: Servant.Handler LazyText.Text
     styleHandler = pure Style.css
@@ -988,7 +1135,8 @@ server =
         Nothing -> pure (TaskDetailNotFound tid)
         Just task -> do
           activities <- liftIO (TaskCore.getActivitiesForTask tid)
-          pure (TaskDetailFound task tasks activities)
+          retryCtx <- liftIO (TaskCore.getRetryContext tid)
+          pure (TaskDetailFound task tasks activities retryCtx)
 
     taskStatusHandler :: Text -> StatusForm -> Servant.Handler StatusBadgePartial
     taskStatusHandler tid (StatusForm newStatus) = do
@@ -1056,6 +1204,12 @@ server =
           filteredTasks = applyFilters filters allTasks
       pure (TaskListPartial filteredTasks)
 
+    taskMetricsPartialHandler :: Text -> Servant.Handler TaskMetricsPartial
+    taskMetricsPartialHandler tid = do
+      activities <- liftIO (TaskCore.getActivitiesForTask tid)
+      maybeRetry <- liftIO (TaskCore.getRetryContext tid)
+      pure (TaskMetricsPartial tid activities maybeRetry)
+
 getReviewInfo :: Text -> IO ReviewInfo
 getReviewInfo tid = do
   maybeCommit <- findCommitForTask tid
diff --git a/Omni/Jr/Web/Style.hs b/Omni/Jr/Web/Style.hs
index dbe1daad..b4f4c767 100644
--- a/Omni/Jr/Web/Style.hs
+++ b/Omni/Jr/Web/Style.hs
@@ -28,6 +28,7 @@ stylesheet = do
   statusBadges
   buttonStyles
   formStyles
+  executionDetailsStyles
   activityTimelineStyles
   markdownStyles
   responsiveStyles
@@ -497,6 +498,39 @@ formStyles = do
   ".form-actions" ? do
     marginTop (px 8)
 
+executionDetailsStyles :: Css
+executionDetailsStyles = do
+  ".execution-section" ? do
+    marginTop (em 1)
+    backgroundColor white
+    borderRadius (px 2) (px 2) (px 2) (px 2)
+    padding (px 8) (px 10) (px 8) (px 10)
+    border (px 1) solid "#d0d0d0"
+  ".execution-details" ? do
+    marginTop (px 8)
+  ".metric-row" ? do
+    display flex
+    flexWrap Flexbox.wrap
+    padding (px 4) (px 0) (px 4) (px 0)
+    borderBottom (px 1) solid "#e5e7eb"
+  ".metric-row" # lastChild ? borderBottom (px 0) none transparent
+  ".metric-label" ? do
+    fontWeight (weight 600)
+    width (px 120)
+    color "#6b7280"
+    fontSize (px 13)
+  ".metric-value" ? do
+    Stylesheet.key "flex" ("1" :: Text)
+    fontSize (px 13)
+  ".amp-link" ? do
+    color "#0066cc"
+    textDecoration none
+    wordBreak breakAll
+  ".amp-link" # hover ? textDecoration underline
+  ".retry-count" ? do
+    color "#f97316"
+    fontWeight (weight 600)
+
 activityTimelineStyles :: Css
 activityTimelineStyles = do
   ".activity-section" ? do
@@ -751,6 +785,13 @@ darkModeStyles =
     ".activity-message" ? color "#d1d5db"
     (".activity-metadata" |> "summary") ? color "#9ca3af"
     ".metadata-json" ? backgroundColor "#374151"
+    ".execution-section" ? do
+      backgroundColor "#1f2937"
+      borderColor "#374151"
+    ".metric-row" ? borderBottomColor "#374151"
+    ".metric-label" ? color "#9ca3af"
+    ".metric-value" ? color "#d1d5db"
+    ".amp-link" ? color "#60a5fa"
     ".markdown-content" ? color "#d1d5db"
     ".md-h1" ? borderBottomColor "#374151"
     ".md-inline-code" ? do
diff --git a/Omni/Task/Core.hs b/Omni/Task/Core.hs
index 3a71900d..ffecd608 100644
--- a/Omni/Task/Core.hs
+++ b/Omni/Task/Core.hs
@@ -93,7 +93,12 @@ data TaskActivity = TaskActivity
     activityTimestamp :: UTCTime,
     activityStage :: ActivityStage,
     activityMessage :: Maybe Text,
-    activityMetadata :: Maybe Text -- JSON for extra data
+    activityMetadata :: Maybe Text, -- JSON for extra data
+    activityAmpThreadUrl :: Maybe Text, -- Link to amp thread
+    activityStartedAt :: Maybe UTCTime, -- When work started
+    activityCompletedAt :: Maybe UTCTime, -- When work completed
+    activityCostCents :: Maybe Int, -- API cost in cents
+    activityTokensUsed :: Maybe Int -- Total tokens used
   }
   deriving (Show, Eq, Generic)
 
@@ -241,6 +246,11 @@ instance SQL.FromRow TaskActivity where
       <*> SQL.field
       <*> SQL.field
       <*> SQL.field
+      <*> SQL.field
+      <*> SQL.field
+      <*> SQL.field
+      <*> SQL.field
+      <*> SQL.field
 
 instance SQL.ToRow TaskActivity where
   toRow a =
@@ -249,7 +259,12 @@ instance SQL.ToRow TaskActivity where
       SQL.toField (activityTimestamp a),
       SQL.toField (activityStage a),
       SQL.toField (activityMessage a),
-      SQL.toField (activityMetadata a)
+      SQL.toField (activityMetadata a),
+      SQL.toField (activityAmpThreadUrl a),
+      SQL.toField (activityStartedAt a),
+      SQL.toField (activityCompletedAt a),
+      SQL.toField (activityCostCents a),
+      SQL.toField (activityTokensUsed a)
     ]
 
 -- | Case-insensitive ID comparison
@@ -352,6 +367,11 @@ initTaskDb = do
       \ stage TEXT NOT NULL, \
       \ message TEXT, \
       \ metadata TEXT, \
+      \ amp_thread_url TEXT, \
+      \ started_at DATETIME, \
+      \ completed_at DATETIME, \
+      \ cost_cents INTEGER, \
+      \ tokens_used INTEGER, \
       \ FOREIGN KEY (task_id) REFERENCES tasks(id) \
       \)"
 
@@ -1011,21 +1031,47 @@ logActivity tid stage metadata =
       "INSERT INTO task_activity (task_id, stage, message, metadata) VALUES (?, ?, ?, ?)"
       (tid, show stage :: String, Nothing :: Maybe Text, metadata)
 
+-- | Log activity with worker metrics (amp thread URL, timing, cost)
+logActivityWithMetrics :: Text -> ActivityStage -> Maybe Text -> Maybe Text -> Maybe UTCTime -> Maybe UTCTime -> Maybe Int -> Maybe Int -> IO Int
+logActivityWithMetrics tid stage metadata ampUrl startedAt completedAt costCents tokens =
+  withDb <| \conn -> do
+    SQL.execute
+      conn
+      "INSERT INTO task_activity (task_id, stage, message, metadata, amp_thread_url, started_at, completed_at, cost_cents, tokens_used) \
+      \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
+      (tid, show stage :: String, Nothing :: Maybe Text, metadata, ampUrl, startedAt, completedAt, costCents, tokens)
+    [SQL.Only actId] <- SQL.query_ conn "SELECT last_insert_rowid()" :: IO [SQL.Only Int]
+    pure actId
+
+-- | Update an existing activity record with metrics
+updateActivityMetrics :: Int -> Maybe Text -> Maybe UTCTime -> Maybe Int -> Maybe Int -> IO ()
+updateActivityMetrics actId ampUrl completedAt costCents tokens =
+  withDb <| \conn ->
+    SQL.execute
+      conn
+      "UPDATE task_activity SET amp_thread_url = COALESCE(?, amp_thread_url), \
+      \completed_at = COALESCE(?, completed_at), \
+      \cost_cents = COALESCE(?, cost_cents), \
+      \tokens_used = COALESCE(?, tokens_used) \
+      \WHERE id = ?"
+      (ampUrl, completedAt, costCents, tokens, actId)
+
 -- | Get all activities for a task, ordered by timestamp descending
 getActivitiesForTask :: Text -> IO [TaskActivity]
 getActivitiesForTask tid =
-  withDb <| \conn -> do
-    rows <-
-      SQL.query
-        conn
-        "SELECT id, task_id, timestamp, stage, message, metadata \
-        \FROM task_activity WHERE task_id = ? ORDER BY timestamp DESC"
-        (SQL.Only tid) ::
-        IO [(Int, Text, UTCTime, Text, Maybe Text, Maybe Text)]
-    pure [TaskActivity (Just i) taskId ts (readStage stg) msg meta | (i, taskId, ts, stg, msg, meta) <- rows]
-  where
-    readStage :: Text -> ActivityStage
-    readStage s = fromMaybe Claiming (readMaybe (T.unpack s))
+  withDb <| \conn ->
+    SQL.query
+      conn
+      "SELECT id, task_id, timestamp, stage, message, metadata, \
+      \amp_thread_url, started_at, completed_at, cost_cents, tokens_used \
+      \FROM task_activity WHERE task_id = ? ORDER BY timestamp DESC"
+      (SQL.Only tid)
+
+-- | Get the most recent running activity for a task (for metrics display)
+getLatestRunningActivity :: Text -> IO (Maybe TaskActivity)
+getLatestRunningActivity tid = do
+  activities <- getActivitiesForTask tid
+  pure <| List.find (\a -> activityStage a == Running) activities
 
 -- | Get tasks with unmet blocking dependencies (not ready, not done)
 getBlockedTasks :: IO [Task]