Fix agent working time persistence across page refreshes (#172)

This commit is contained in:
João Salavisa
2026-02-04 00:07:55 +00:00
committed by GitHub
parent 6fe5b22ec8
commit d9ff49f160
6 changed files with 136 additions and 41 deletions
+54 -18
View File
@@ -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),
}
}
@@ -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(),
+13 -1
View File
@@ -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
@@ -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<number | null>(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';
+5
View File
@@ -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<ListMessagesResponse>(url, options);
}
export async function listSessionTurns(taskSessionId: string, options?: ApiRequestOptions) {
return fetchJson<ListTurnsResponse>(`/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 }>,
+5
View File
@@ -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;