Compare commits

...

1 Commits

Author SHA1 Message Date
Jiayuan
3d2d52dc33 feat(activity): enrich task_completed/task_failed with contextual details
Activity entries for "completed the task" and "task failed" were generic
and repetitive. Now they include:
- issue_title: always present, helps differentiate in cross-issue views
- trigger: "comment" when task was triggered by a comment reply
- pr_url: PR link extracted from task result (for completed tasks)
- error: truncated error message (for failed tasks)

Frontend displays richer text:
- "completed the follow-up" for comment-triggered tasks
- clickable PR link when available
- error summary for failed tasks
2026-04-03 23:58:14 +08:00
4 changed files with 91 additions and 7 deletions

View File

@@ -133,6 +133,24 @@ function InboxDetailLabel({ item }: { item: InboxItem }) {
if (emoji) return <span>Reacted {emoji} to your comment</span>;
return <span>{typeLabels[item.type]}</span>;
}
case "task_completed": {
const label = details.trigger === "comment" ? "Completed follow-up" : "Task completed";
if (details.pr_url) {
return (
<span className="inline-flex items-center gap-1">
{label} {" "}
<a href={details.pr_url} target="_blank" rel="noopener noreferrer" className="underline underline-offset-2">
PR
</a>
</span>
);
}
return <span>{label}</span>;
}
case "task_failed": {
if (details.error) return <span>Task failed: {details.error}</span>;
return <span>{typeLabels[item.type]}</span>;
}
default:
return <span>{typeLabels[item.type] ?? item.type}</span>;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useCallback, useRef, memo } from "react";
import { useState, useEffect, useCallback, useRef, memo, type ReactNode } from "react";
import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -93,7 +93,7 @@ function priorityLabel(priority: string): string {
function formatActivity(
entry: TimelineEntry,
resolveActorName?: (type: string, id: string) => string,
): string {
): ReactNode {
const details = (entry.details ?? {}) as Record<string, string>;
switch (entry.action) {
case "created":
@@ -121,10 +121,24 @@ function formatActivity(
return `renamed this issue from "${details.from ?? "?"}" to "${details.to ?? "?"}"`;
case "description_updated":
return "updated the description";
case "task_completed":
return "completed the task";
case "task_failed":
case "task_completed": {
const label = details.trigger === "comment" ? "completed the follow-up" : "completed the task";
if (details.pr_url) {
return (
<>
{label} {" "}
<a href={details.pr_url} target="_blank" rel="noopener noreferrer" className="text-foreground underline underline-offset-2 hover:text-foreground/80">
PR
</a>
</>
);
}
return label;
}
case "task_failed": {
if (details.error) return `task failed: ${details.error}`;
return "task failed";
}
default:
return entry.action ?? "";
}

View File

@@ -230,11 +230,12 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
}
agentID, _ := payload["agent_id"].(string)
issueID, _ := payload["issue_id"].(string)
taskID, _ := payload["task_id"].(string)
if issueID == "" {
return
}
// Look up issue to get workspace_id
// Look up issue to get workspace_id and title
issue, err := queries.GetIssue(ctx, parseUUID(issueID))
if err != nil {
slog.Error("activity: failed to get issue for task event",
@@ -242,13 +243,46 @@ func handleTaskActivity(ctx context.Context, bus *events.Bus, queries *db.Querie
return
}
// Build enriched details from the task record
detailsMap := map[string]string{
"issue_title": issue.Title,
}
if taskID != "" {
if task, err := queries.GetAgentTask(ctx, parseUUID(taskID)); err == nil {
// Trigger type: comment-triggered vs assignment-triggered
if task.TriggerCommentID.Valid {
detailsMap["trigger"] = "comment"
}
if action == "task_completed" && len(task.Result) > 0 {
var completed protocol.TaskCompletedPayload
if err := json.Unmarshal(task.Result, &completed); err == nil {
if completed.PRURL != "" {
detailsMap["pr_url"] = completed.PRURL
}
}
}
if action == "task_failed" && task.Error.Valid && task.Error.String != "" {
// Truncate long error messages for the activity summary
errMsg := task.Error.String
if len(errMsg) > 200 {
errMsg = errMsg[:200] + "…"
}
detailsMap["error"] = errMsg
}
}
}
details, _ := json.Marshal(detailsMap)
activity, err := queries.CreateActivity(ctx, db.CreateActivityParams{
WorkspaceID: issue.WorkspaceID,
IssueID: parseUUID(issueID),
ActorType: util.StrToText("agent"),
ActorID: parseUUID(agentID),
Action: action,
Details: []byte("{}"),
Details: details,
})
if err != nil {
slog.Error("activity: failed to record task activity",

View File

@@ -308,6 +308,15 @@ func TestActivityTaskCompleted(t *testing.T) {
if util.UUIDToString(activities[0].ActorID) != agentID {
t.Fatalf("expected actor_id %s, got %s", agentID, util.UUIDToString(activities[0].ActorID))
}
// Verify enriched details contain issue_title
var details map[string]string
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
t.Fatalf("failed to unmarshal details: %v", err)
}
if details["issue_title"] != "subscriber test issue" {
t.Fatalf("expected issue_title 'subscriber test issue', got %q", details["issue_title"])
}
}
func TestActivityTaskFailed(t *testing.T) {
@@ -343,4 +352,13 @@ func TestActivityTaskFailed(t *testing.T) {
if activities[0].Action != "task_failed" {
t.Fatalf("expected action 'task_failed', got %q", activities[0].Action)
}
// Verify enriched details contain issue_title
var details map[string]string
if err := json.Unmarshal(activities[0].Details, &details); err != nil {
t.Fatalf("failed to unmarshal details: %v", err)
}
if details["issue_title"] != "subscriber test issue" {
t.Fatalf("expected issue_title 'subscriber test issue', got %q", details["issue_title"])
}
}