+
-
-
-
{agentName ?? "Agent"} is working
+
+
+ {agentName ?? "Agent"} is working
-
{elapsed}
- {toolCalls.length > 0 && (
-
- {toolCalls.length} tool {toolCalls.length === 1 ? "call" : "calls"}
+ {elapsed}
+ {toolCount > 0 && (
+
+ {toolCount} tool {toolCount === 1 ? "call" : "calls"}
)}
- {/* Content */}
- {(toolCalls.length > 0 || currentText) && (
+ {/* Timeline content */}
+ {items.length > 0 && (
-
- {toolCalls.map((tc, idx) => (
-
- ))}
+ {items.map((item, idx) => (
+
+ ))}
- {/* Current thinking text (last line only) */}
- {lastLine && (
-
-
- {lastLine}
-
- )}
-
-
- {/* Scroll to bottom button */}
{!autoScroll && (
+ {/* Agent execution history */}
+
+
+
+
{/* Timeline entries */}
{(() => {
diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts
index 89e2f107..8483a1ad 100644
--- a/apps/web/shared/api/client.ts
+++ b/apps/web/shared/api/client.ts
@@ -318,6 +318,10 @@ export class ApiClient {
return this.fetch(`/api/daemon/tasks/${taskId}/messages`);
}
+ async listTasksByIssue(issueId: string): Promise
{
+ return this.fetch(`/api/issues/${issueId}/task-runs`);
+ }
+
async getDaemonPairingSession(token: string): Promise {
return this.fetch(`/api/daemon/pairing-sessions/${token}`);
}
diff --git a/apps/web/shared/types/events.ts b/apps/web/shared/types/events.ts
index d5eab3bf..4e22b5e2 100644
--- a/apps/web/shared/types/events.ts
+++ b/apps/web/shared/types/events.ts
@@ -153,7 +153,7 @@ export interface TaskMessagePayload {
task_id: string;
issue_id: string;
seq: number;
- type: "text" | "tool_use" | "tool_result" | "error";
+ type: "text" | "thinking" | "tool_use" | "tool_result" | "error";
tool?: string;
content?: string;
input?: Record;
diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go
index ffa0c714..be9e7ff4 100644
--- a/server/cmd/server/router.go
+++ b/server/cmd/server/router.go
@@ -167,6 +167,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route
r.Post("/subscribe", h.SubscribeToIssue)
r.Post("/unsubscribe", h.UnsubscribeFromIssue)
r.Get("/active-task", h.GetActiveTaskForIssue)
+ r.Get("/task-runs", h.ListTasksByIssue)
})
})
diff --git a/server/internal/daemon/daemon.go b/server/internal/daemon/daemon.go
index 158370a4..d9b66c58 100644
--- a/server/internal/daemon/daemon.go
+++ b/server/internal/daemon/daemon.go
@@ -827,10 +827,22 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
var seq atomic.Int32
var mu sync.Mutex
var pendingText strings.Builder
+ var pendingThinking strings.Builder
var batch []TaskMessageData
+ callIDToTool := map[string]string{} // track callID → tool name for tool_result
flush := func() {
mu.Lock()
+ // Flush any accumulated thinking as a single message.
+ if pendingThinking.Len() > 0 {
+ s := seq.Add(1)
+ batch = append(batch, TaskMessageData{
+ Seq: int(s),
+ Type: "thinking",
+ Content: pendingThinking.String(),
+ })
+ pendingThinking.Reset()
+ }
// Flush any accumulated text as a single message.
if pendingText.Len() > 0 {
s := seq.Add(1)
@@ -854,7 +866,7 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
}
}
- // Periodically flush accumulated text messages.
+ // Periodically flush accumulated text/thinking messages.
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
@@ -875,30 +887,47 @@ func (d *Daemon) runTask(ctx context.Context, task Task, provider string, taskLo
case agent.MessageToolUse:
n := toolCount.Add(1)
taskLog.Info(fmt.Sprintf("tool #%d: %s", n, msg.Tool))
+ if msg.CallID != "" {
+ mu.Lock()
+ callIDToTool[msg.CallID] = msg.Tool
+ mu.Unlock()
+ }
s := seq.Add(1)
mu.Lock()
batch = append(batch, TaskMessageData{
- Seq: int(s),
- Type: "tool_use",
- Tool: msg.Tool,
+ Seq: int(s),
+ Type: "tool_use",
+ Tool: msg.Tool,
Input: msg.Input,
})
mu.Unlock()
case agent.MessageToolResult:
s := seq.Add(1)
- // Truncate large tool results for the live feed.
output := msg.Output
if len(output) > 8192 {
output = output[:8192]
}
+ // Resolve tool name from callID if not set directly.
+ toolName := msg.Tool
+ if toolName == "" && msg.CallID != "" {
+ mu.Lock()
+ toolName = callIDToTool[msg.CallID]
+ mu.Unlock()
+ }
mu.Lock()
batch = append(batch, TaskMessageData{
Seq: int(s),
Type: "tool_result",
- Tool: msg.Tool,
+ Tool: toolName,
Output: output,
})
mu.Unlock()
+ case agent.MessageThinking:
+ if msg.Content != "" {
+ mu.Lock()
+ pendingThinking.WriteString(msg.Content)
+ mu.Unlock()
+ }
case agent.MessageText:
if msg.Content != "" {
taskLog.Debug("agent", "text", truncateLog(msg.Content, 200))
diff --git a/server/internal/handler/daemon.go b/server/internal/handler/daemon.go
index fd209ec9..b5bf102c 100644
--- a/server/internal/handler/daemon.go
+++ b/server/internal/handler/daemon.go
@@ -503,3 +503,21 @@ func (h *Handler) GetActiveTaskForIssue(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]any{"task": taskToResponse(tasks[0])})
}
+
+// ListTasksByIssue returns all tasks (any status) for an issue — used for execution history.
+func (h *Handler) ListTasksByIssue(w http.ResponseWriter, r *http.Request) {
+ issueID := chi.URLParam(r, "id")
+
+ tasks, err := h.Queries.ListTasksByIssue(r.Context(), parseUUID(issueID))
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to list tasks")
+ return
+ }
+
+ resp := make([]AgentTaskResponse, len(tasks))
+ for i, t := range tasks {
+ resp[i] = taskToResponse(t)
+ }
+
+ writeJSON(w, http.StatusOK, resp)
+}
diff --git a/server/pkg/agent/agent.go b/server/pkg/agent/agent.go
index d19887ed..d80a2641 100644
--- a/server/pkg/agent/agent.go
+++ b/server/pkg/agent/agent.go
@@ -42,6 +42,7 @@ type MessageType string
const (
MessageText MessageType = "text"
+ MessageThinking MessageType = "thinking"
MessageToolUse MessageType = "tool-use"
MessageToolResult MessageType = "tool-result"
MessageStatus MessageType = "status"
diff --git a/server/pkg/agent/claude.go b/server/pkg/agent/claude.go
index 610df39c..c1b78406 100644
--- a/server/pkg/agent/claude.go
+++ b/server/pkg/agent/claude.go
@@ -181,6 +181,10 @@ func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message,
output.WriteString(block.Text)
trySend(ch, Message{Type: MessageText, Content: block.Text})
}
+ case "thinking":
+ if block.Text != "" {
+ trySend(ch, Message{Type: MessageThinking, Content: block.Text})
+ }
case "tool_use":
var input map[string]any
if block.Input != nil {
diff --git a/server/pkg/db/generated/agent.sql.go b/server/pkg/db/generated/agent.sql.go
index 3657c088..1de2793f 100644
--- a/server/pkg/db/generated/agent.sql.go
+++ b/server/pkg/db/generated/agent.sql.go
@@ -621,6 +621,48 @@ func (q *Queries) ListPendingTasksByRuntime(ctx context.Context, runtimeID pgtyp
return items, nil
}
+const listTasksByIssue = `-- name: ListTasksByIssue :many
+SELECT id, agent_id, issue_id, status, priority, dispatched_at, started_at, completed_at, result, error, created_at, context, runtime_id, session_id, work_dir FROM agent_task_queue
+WHERE issue_id = $1
+ORDER BY created_at DESC
+`
+
+func (q *Queries) ListTasksByIssue(ctx context.Context, issueID pgtype.UUID) ([]AgentTaskQueue, error) {
+ rows, err := q.db.Query(ctx, listTasksByIssue, issueID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ items := []AgentTaskQueue{}
+ for rows.Next() {
+ var i AgentTaskQueue
+ if err := rows.Scan(
+ &i.ID,
+ &i.AgentID,
+ &i.IssueID,
+ &i.Status,
+ &i.Priority,
+ &i.DispatchedAt,
+ &i.StartedAt,
+ &i.CompletedAt,
+ &i.Result,
+ &i.Error,
+ &i.CreatedAt,
+ &i.Context,
+ &i.RuntimeID,
+ &i.SessionID,
+ &i.WorkDir,
+ ); err != nil {
+ return nil, err
+ }
+ items = append(items, i)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return items, nil
+}
+
const startAgentTask = `-- name: StartAgentTask :one
UPDATE agent_task_queue
SET status = 'running', started_at = now()
diff --git a/server/pkg/db/queries/agent.sql b/server/pkg/db/queries/agent.sql
index dce917ba..4540a7e9 100644
--- a/server/pkg/db/queries/agent.sql
+++ b/server/pkg/db/queries/agent.sql
@@ -134,6 +134,11 @@ SELECT * FROM agent_task_queue
WHERE issue_id = $1 AND status IN ('dispatched', 'running')
ORDER BY created_at DESC;
+-- name: ListTasksByIssue :many
+SELECT * FROM agent_task_queue
+WHERE issue_id = $1
+ORDER BY created_at DESC;
+
-- name: UpdateAgentStatus :one
UPDATE agent SET status = $2, updated_at = now()
WHERE id = $1