Fix agent working time persistence across page refreshes (#172)
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 }>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user