From d9ff49f160369a8f8ce632389fe3a72e2deb2cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Wed, 4 Feb 2026 00:07:55 +0000 Subject: [PATCH] Fix agent working time persistence across page refreshes (#172) --- apps/backend/internal/task/dto/dto.go | 72 ++++++++++++++----- .../internal/task/handlers/task_handlers.go | 24 +++++++ apps/web/app/s/[sessionId]/page.tsx | 14 +++- .../task/chat/messages/agent-status.tsx | 57 +++++++++------ apps/web/lib/api/domains/session-api.ts | 5 ++ apps/web/lib/types/http.ts | 5 ++ 6 files changed, 136 insertions(+), 41 deletions(-) diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 42599f0a8c..ade672fd67 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -30,24 +30,24 @@ type WorkspaceDTO struct { } type RepositoryDTO struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - SourceType string `json:"source_type"` - LocalPath string `json:"local_path"` - Provider string `json:"provider"` - ProviderRepoID string `json:"provider_repo_id"` - ProviderOwner string `json:"provider_owner"` - ProviderName string `json:"provider_name"` - DefaultBranch string `json:"default_branch"` - WorktreeBranchPrefix string `json:"worktree_branch_prefix"` - PullBeforeWorktree bool `json:"pull_before_worktree"` - SetupScript string `json:"setup_script"` - CleanupScript string `json:"cleanup_script"` - DevScript string `json:"dev_script"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - Scripts []RepositoryScriptDTO `json:"scripts,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + SourceType string `json:"source_type"` + LocalPath string `json:"local_path"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + DefaultBranch string `json:"default_branch"` + WorktreeBranchPrefix string `json:"worktree_branch_prefix"` + PullBeforeWorktree bool `json:"pull_before_worktree"` + SetupScript string `json:"setup_script"` + CleanupScript string `json:"cleanup_script"` + DevScript string `json:"dev_script"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Scripts []RepositoryScriptDTO `json:"scripts,omitempty"` } type RepositoryScriptDTO struct { @@ -166,6 +166,22 @@ type ListMessagesResponse struct { Cursor string `json:"cursor"` } +type TurnDTO struct { + ID string `json:"id"` + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + StartedAt string `json:"started_at"` + CompletedAt *string `json:"completed_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type ListTurnsResponse struct { + Turns []TurnDTO `json:"turns"` + Total int `json:"total"` +} + type ListBoardsResponse struct { Boards []BoardDTO `json:"boards"` Total int `json:"total"` @@ -488,3 +504,23 @@ func TaskPlanFromModel(plan *models.TaskPlan) *TaskPlanDTO { UpdatedAt: plan.UpdatedAt, } } + +// FromTurn converts a Turn model to a TurnDTO. +func FromTurn(turn *models.Turn) TurnDTO { + var completedAt *string + if turn.CompletedAt != nil { + formatted := turn.CompletedAt.UTC().Format(time.RFC3339) + completedAt = &formatted + } + + return TurnDTO{ + ID: turn.ID, + SessionID: turn.TaskSessionID, + TaskID: turn.TaskID, + StartedAt: turn.StartedAt.UTC().Format(time.RFC3339), + CompletedAt: completedAt, + Metadata: turn.Metadata, + CreatedAt: turn.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: turn.UpdatedAt.UTC().Format(time.RFC3339), + } +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 7b3e8f8219..28e40e4bf1 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -63,6 +63,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.GET("/tasks/:id", h.httpGetTask) api.GET("/task-sessions/:id", h.httpGetTaskSession) api.GET("/tasks/:id/sessions", h.httpListTaskSessions) + api.GET("/task-sessions/:id/turns", h.httpListSessionTurns) api.POST("/tasks", h.httpCreateTask) api.PATCH("/tasks/:id", h.httpUpdateTask) api.POST("/tasks/:id/move", h.httpMoveTask) @@ -163,6 +164,29 @@ func (h *TaskHandlers) httpGetTaskSession(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *TaskHandlers) httpListSessionTurns(c *gin.Context) { + sessionID := c.Param("id") + if sessionID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "session id is required"}) + return + } + + turns, err := h.repo.ListTurnsBySession(c.Request.Context(), sessionID) + if err != nil { + h.logger.Error("failed to list turns", zap.String("session_id", sessionID), zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list turns"}) + return + } + + // Convert to DTO + turnDTOs := make([]dto.TurnDTO, 0, len(turns)) + for _, turn := range turns { + turnDTOs = append(turnDTOs, dto.FromTurn(turn)) + } + + c.JSON(http.StatusOK, dto.ListTurnsResponse{Turns: turnDTOs, Total: len(turnDTOs)}) +} + func (h *TaskHandlers) httpApproveSession(c *gin.Context) { resp, err := h.controller.ApproveSession( c.Request.Context(), diff --git a/apps/web/app/s/[sessionId]/page.tsx b/apps/web/app/s/[sessionId]/page.tsx index 7917db5fd8..5e5b1b5020 100644 --- a/apps/web/app/s/[sessionId]/page.tsx +++ b/apps/web/app/s/[sessionId]/page.tsx @@ -12,6 +12,7 @@ import { listTaskSessions, listWorkspaces, } from '@/lib/api'; +import { listSessionTurns } from '@/lib/api/domains/session-api'; import type { ListMessagesResponse, Task } from '@/lib/types/http'; import { snapshotToState, taskToState } from '@/lib/ssr/mapper'; import { TaskPageContent } from '@/components/task/task-page-content'; @@ -37,7 +38,7 @@ export default async function SessionPage({ } task = await fetchTask(session.task_id, { cache: 'no-store' }); - const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse] = await Promise.all([ + const [snapshot, agents, repositoriesResponse, allSessionsResponse, availableAgentsResponse, workspacesResponse, boardsResponse, turnsResponse] = await Promise.all([ fetchBoardSnapshot(task.board_id, { cache: 'no-store' }), listAgents({ cache: 'no-store' }), listRepositories(task.workspace_id, { includeScripts: true }, { cache: 'no-store' }), @@ -45,12 +46,14 @@ export default async function SessionPage({ listAvailableAgents({ cache: 'no-store' }).catch(() => ({ agents: [] })), listWorkspaces({ cache: 'no-store' }).catch(() => ({ workspaces: [] })), listBoards(task.workspace_id, { cache: 'no-store' }).catch(() => ({ boards: [] })), + listSessionTurns(sessionId, { cache: 'no-store' }).catch(() => ({ turns: [], total: 0 })), ]); const repositories = repositoriesResponse.repositories ?? []; const allSessions = allSessionsResponse.sessions ?? [session]; const availableAgents = availableAgentsResponse.agents ?? []; const workspaces = workspacesResponse.workspaces ?? []; const boards = boardsResponse.boards ?? []; + const turns = turnsResponse.turns ?? []; // Get repository scripts from the repository (already fetched with includeScripts) const repositoryId = task.repositories?.[0]?.repository_id; @@ -155,6 +158,15 @@ export default async function SessionPage({ [task.id]: true, }, }, + turns: { + bySession: { + [sessionId]: turns, + }, + activeBySession: { + // Find the most recent (last) incomplete turn + [sessionId]: turns.filter((t) => !t.completed_at).pop()?.id ?? null, + }, + }, worktrees: { items: Object.fromEntries( allSessions diff --git a/apps/web/components/task/chat/messages/agent-status.tsx b/apps/web/components/task/chat/messages/agent-status.tsx index 218690e217..35828dcea1 100644 --- a/apps/web/components/task/chat/messages/agent-status.tsx +++ b/apps/web/components/task/chat/messages/agent-status.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; +import { useCallback, useMemo, useState, useEffect } from 'react'; import { IconAlertCircle, IconAlertTriangle, IconCopy, IconCheck } from '@tabler/icons-react'; import type { Message, TaskSessionState } from '@/lib/types/http'; import { useSessionTurn } from '@/hooks/domains/session/use-session-turn'; +import { useAppStore } from '@/components/state-provider'; import { GridSpinner } from '@/components/grid-spinner'; type AgentStatusProps = { @@ -68,33 +69,37 @@ function calculateTurnDurationFromMessages(messages: Message[]): string | null { /** * Hook to track elapsed time while agent is running. - * Starts timer when isRunning becomes true, resets when it becomes false. + * Uses the turn's started_at timestamp to calculate elapsed time, so it persists across page refreshes. */ -function useRunningTimer(isRunning: boolean) { +function useRunningTimer(isRunning: boolean, turnStartedAt: string | null) { const [elapsedSeconds, setElapsedSeconds] = useState(0); - const startTimeRef = useRef(null); useEffect(() => { - if (!isRunning) { - // Reset refs when not running (state reset handled below) - startTimeRef.current = null; + if (!isRunning || !turnStartedAt) { return; } - // Start timer - if (!startTimeRef.current) { - startTimeRef.current = Date.now(); - } - const interval = setInterval(() => { - if (startTimeRef.current) { - setElapsedSeconds(Math.floor((Date.now() - startTimeRef.current) / 1000)); - } - }, 1000); - return () => clearInterval(interval); - }, [isRunning]); + const startTime = new Date(turnStartedAt).getTime(); - // Reset elapsed seconds when not running (outside effect to avoid lint warning) - const displaySeconds = isRunning ? elapsedSeconds : 0; + const updateElapsed = () => { + const elapsed = Math.floor((Date.now() - startTime) / 1000); + setElapsedSeconds(elapsed); + }; + + // Update in the interval callback (not synchronously in effect) + const interval = setInterval(updateElapsed, 1000); + + // Also update once immediately, but in the next tick to avoid sync update + const timeoutId = setTimeout(updateElapsed, 0); + + return () => { + clearInterval(interval); + clearTimeout(timeoutId); + }; + }, [isRunning, turnStartedAt]); + + // Reset to 0 when not running + const displaySeconds = isRunning && turnStartedAt ? elapsedSeconds : 0; // Format as Xs or XmXs const formatted = useMemo(() => { @@ -138,8 +143,16 @@ export function AgentStatus({ sessionState, sessionId, messages = [] }: AgentSta const config = sessionState ? STATE_CONFIG[sessionState] : null; const isRunning = config?.icon === 'spinner'; - // Use local timer that starts when agent is running - const { formatted: runningDuration, elapsedSeconds } = useRunningTimer(isRunning); + // Get active turn to use its started_at timestamp for the timer + const turns = useAppStore((state) => sessionId ? state.turns.bySession[sessionId] : undefined); + const activeTurnId = useAppStore((state) => sessionId ? state.turns.activeBySession[sessionId] : null); + const activeTurn = useMemo(() => { + if (!turns || !activeTurnId) return null; + return turns.find((t) => t.id === activeTurnId) ?? null; + }, [turns, activeTurnId]); + + // Use timer that uses the turn's started_at timestamp, so it persists across page refreshes + const { formatted: runningDuration, elapsedSeconds } = useRunningTimer(isRunning, activeTurn?.started_at ?? null); const isError = config?.icon === 'error'; const isWarning = config?.icon === 'warning'; diff --git a/apps/web/lib/api/domains/session-api.ts b/apps/web/lib/api/domains/session-api.ts index a4036c86d0..73e78c06d6 100644 --- a/apps/web/lib/api/domains/session-api.ts +++ b/apps/web/lib/api/domains/session-api.ts @@ -3,6 +3,7 @@ import type { TaskSessionsResponse, TaskSessionResponse, ListMessagesResponse, + ListTurnsResponse, } from '@/lib/types/http'; // Session operations @@ -29,6 +30,10 @@ export async function listTaskSessionMessages( return fetchJson(url, options); } +export async function listSessionTurns(taskSessionId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/task-sessions/${taskSessionId}/turns`, options); +} + export async function openSessionInEditor( sessionId: string, payload: Partial<{ editor_id: string; editor_type: string; file_path: string; line: number; column: number }>, diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 99cbddcfcb..69bd374bed 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -508,6 +508,11 @@ export type Turn = { updated_at: string; }; +export type ListTurnsResponse = { + turns: Turn[]; + total: number; +}; + export type AgentProfile = { id: string; agent_id: string;