diff --git a/apps/dashboard/src/components/RunSourceToolbar.tsx b/apps/dashboard/src/components/RunSourceToolbar.tsx index 95535f80f..432c8a0a4 100644 --- a/apps/dashboard/src/components/RunSourceToolbar.tsx +++ b/apps/dashboard/src/components/RunSourceToolbar.tsx @@ -1,6 +1,6 @@ import { - formatLastSynced, - formatRemoteRunCount, + buildRemoteErrorAction, + buildRemoteStatusItems, getProjectSyncView, } from '~/lib/project-sync-status'; import type { RemoteStatusResponse } from '~/lib/types'; @@ -44,6 +44,7 @@ export function RunSourceToolbar({ : 'border-red-900/60 bg-red-950/20 text-red-300'; const dirtyPathCount = remoteStatus?.dirty_paths?.length ?? 0; const conflictedPathCount = remoteStatus?.conflicted_paths?.length ?? 0; + const remoteStatusItems = buildRemoteStatusItems(remoteStatus, projectName); return (
@@ -86,7 +87,7 @@ export function RunSourceToolbar({ title={!syncView.canSync ? syncView.nextAction : undefined} className="rounded-md border border-cyan-800 bg-cyan-950/40 px-3 py-1.5 text-sm font-medium text-cyan-300 transition-colors hover:bg-cyan-900/50 disabled:cursor-not-allowed disabled:opacity-60" > - {syncInFlight ? 'Syncing...' : 'Sync Project'} + {syncInFlight ? 'Syncing...' : 'Sync Remote Results'} ) : null}
@@ -96,10 +97,9 @@ export function RunSourceToolbar({ {remoteConfigured ? (
- {projectName ? Project: {projectName} : null} - {formatRemoteRunCount(remoteStatus?.run_count)} - {formatLastSynced(remoteStatus?.last_synced_at)} - {remoteStatus?.repo ? Repo: {remoteStatus.repo} : null} + {remoteStatusItems.map((item) => ( + {item} + ))}

{syncView.summary} @@ -132,14 +132,22 @@ export function RunSourceToolbar({ ) : null} {syncFeedback ? ( -

+
{syncFeedback.message}
) : null} {remoteStatus?.last_error ? ( -
- {remoteStatus.last_error} +
+

Remote sync error

+

{remoteStatus.last_error}

+

{buildRemoteErrorAction(remoteStatus)}

) : null} diff --git a/apps/dashboard/src/lib/project-sync-status.test.ts b/apps/dashboard/src/lib/project-sync-status.test.ts index 2f92ac940..e956ae6b9 100644 --- a/apps/dashboard/src/lib/project-sync-status.test.ts +++ b/apps/dashboard/src/lib/project-sync-status.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'bun:test'; import { + buildProjectSyncErrorFeedback, buildProjectSyncFeedback, + buildRemoteStatusItems, formatRemoteRunCount, getProjectSyncView, } from './project-sync-status'; @@ -49,20 +51,39 @@ describe('getProjectSyncView', () => { describe('buildProjectSyncFeedback', () => { it('summarizes successful sync actions', () => { - expect( - buildProjectSyncFeedback({ - configured: true, - available: true, - sync_status: 'clean', - commit_created: true, - pull_performed: true, - push_performed: true, - }), - ).toEqual({ - kind: 'success', - message: - 'Sync complete: committed pending metadata, pulled remote results, pushed local results.', + const feedback = buildProjectSyncFeedback({ + configured: true, + available: true, + sync_status: 'clean', + commit_created: true, + pull_performed: true, + push_performed: true, + run_count: 2, + last_synced_at: '2026-06-06T13:00:00.000Z', }); + + expect(feedback.kind).toBe('success'); + expect(feedback.message).toContain( + 'Sync completed: committed pending metadata, pulled remote results, pushed local results.', + ); + }); + + it('confirms WTG-like manual sync with repo, run count, and sync time', () => { + const feedback = buildProjectSyncFeedback({ + configured: true, + available: true, + sync_status: 'clean', + repo: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults', + run_count: 1, + last_synced_at: '2026-06-06T13:00:00.000Z', + pull_performed: true, + }); + + expect(feedback.kind).toBe('success'); + expect(feedback.message).toContain('Synced 1 remote run'); + expect(feedback.message).toContain('WiseTechGlobal/WTG.AI.Prompts.EvalResults'); + expect(feedback.message).toContain(' at '); + expect(feedback.message).toContain('pulled remote results'); }); it('keeps blocked sync feedback explicit', () => { @@ -76,9 +97,22 @@ describe('buildProjectSyncFeedback', () => { }), ).toEqual({ kind: 'warning', - message: 'Results repo has unresolved git conflicts', + message: + 'Sync stopped: Results repo has unresolved git conflicts. The remote results cache remains available. Resolve the results repo issue, then sync remote results again.', }); }); + + it('builds actionable sync failure feedback without hiding cached remote runs', () => { + expect( + buildProjectSyncErrorFeedback(new Error('GitHub authentication failed'), { + configured: true, + available: true, + run_count: 1, + }).message, + ).toBe( + 'Sync failed: GitHub authentication failed. Cached 1 remote run remains available. Resolve the results repo issue, then sync remote results again.', + ); + }); }); describe('formatRemoteRunCount', () => { @@ -87,3 +121,19 @@ describe('formatRemoteRunCount', () => { expect(formatRemoteRunCount(2)).toBe('2 remote runs'); }); }); + +describe('buildRemoteStatusItems', () => { + it('includes WTG-like repo, remote run count, and last sync time', () => { + const items = buildRemoteStatusItems({ + configured: true, + available: true, + repo: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults', + run_count: 1, + last_synced_at: '2026-06-06T13:00:00.000Z', + }); + + expect(items).toContain('1 remote run'); + expect(items).toContain('Repo: WiseTechGlobal/WTG.AI.Prompts.EvalResults'); + expect(items.some((item) => item.startsWith('Last synced '))).toBe(true); + }); +}); diff --git a/apps/dashboard/src/lib/project-sync-status.ts b/apps/dashboard/src/lib/project-sync-status.ts index c8eefa18c..cc91e33f5 100644 --- a/apps/dashboard/src/lib/project-sync-status.ts +++ b/apps/dashboard/src/lib/project-sync-status.ts @@ -21,17 +21,22 @@ export interface ProjectSyncView { canSync: boolean; } -export function formatLastSynced(timestamp?: string): string { +function formatTimestamp(timestamp?: string): string | undefined { if (!timestamp) { - return 'Never synced'; + return undefined; } const parsed = new Date(timestamp); if (Number.isNaN(parsed.getTime())) { - return 'Never synced'; + return undefined; } - return `Last synced ${parsed.toLocaleString()}`; + return parsed.toLocaleString(); +} + +export function formatLastSynced(timestamp?: string): string { + const formatted = formatTimestamp(timestamp); + return formatted ? `Last synced ${formatted}` : 'Never synced'; } export function formatRemoteRunCount(count?: number): string { @@ -41,6 +46,22 @@ export function formatRemoteRunCount(count?: number): string { return `${count} remote run${count === 1 ? '' : 's'}`; } +export function buildRemoteStatusItems( + status: RemoteStatusResponse | undefined, + projectName?: string, +): string[] { + if (status?.configured !== true) { + return []; + } + + return [ + projectName ? `Project: ${projectName}` : undefined, + formatRemoteRunCount(status.run_count), + formatLastSynced(status.last_synced_at), + status.repo ? `Repo: ${status.repo}` : undefined, + ].filter((item): item is string => item !== undefined); +} + export function getProjectSyncView( status: RemoteStatusResponse | undefined, syncInFlight = false, @@ -140,14 +161,63 @@ export function getProjectSyncView( }; } +function formatSyncOutcomeRuns(count?: number): string { + if (typeof count !== 'number' || !Number.isFinite(count)) { + return 'remote results'; + } + return formatRemoteRunCount(count); +} + +function buildSyncOutcomeSentence(status: RemoteStatusResponse): string { + const parts = [`Synced ${formatSyncOutcomeRuns(status.run_count)}`]; + if (status.repo) { + parts.push(`from ${status.repo}`); + } + + const syncedAt = formatTimestamp(status.last_synced_at); + if (syncedAt) { + parts.push(`at ${syncedAt}`); + } + + return `${parts.join(' ')}.`; +} + +function buildCachedRemoteAvailability( + status: RemoteStatusResponse | undefined, +): string | undefined { + if (status?.available !== true) { + return undefined; + } + + const runCount = status.run_count; + if (typeof runCount === 'number' && Number.isFinite(runCount) && runCount > 0) { + return `Cached ${formatRemoteRunCount(runCount)} ${ + runCount === 1 ? 'remains' : 'remain' + } available.`; + } + + return 'The remote results cache remains available.'; +} + +export function buildRemoteErrorAction(status: RemoteStatusResponse | undefined): string { + return [ + buildCachedRemoteAvailability(status), + 'Resolve the results repo issue, then sync remote results again.', + ] + .filter((part): part is string => part !== undefined) + .join(' '); +} + export function buildProjectSyncFeedback(status: RemoteStatusResponse): { kind: 'success' | 'warning'; message: string; } { if (status.blocked || status.sync_status === 'conflicted' || status.sync_status === 'diverged') { + const repo = status.repo ? ` for ${status.repo}` : ''; + const reason = status.block_reason ?? 'Sync stopped before changing the results repo.'; return { kind: 'warning', - message: status.block_reason ?? 'Sync stopped before changing the results repo.', + message: `Sync stopped${repo}: ${reason}. ${buildRemoteErrorAction(status)}`, }; } @@ -161,7 +231,21 @@ export function buildProjectSyncFeedback(status: RemoteStatusResponse): { kind: 'success', message: actions.length > 0 - ? `Sync complete: ${actions.join(', ')}.` - : 'Sync complete; project results are up to date.', + ? `${buildSyncOutcomeSentence(status)} Sync completed: ${actions.join(', ')}.` + : `${buildSyncOutcomeSentence(status)} Project results were already up to date.`, + }; +} + +export function buildProjectSyncErrorFeedback( + error: unknown, + status: RemoteStatusResponse | undefined, +): { + kind: 'error'; + message: string; +} { + const message = error instanceof Error ? error.message : String(error); + return { + kind: 'error', + message: `Sync failed: ${message}. ${buildRemoteErrorAction(status)}`, }; } diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index 8709d3679..5ce3df631 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -33,7 +33,7 @@ import { resolveIndexRoute, resolveInitialProjectRedirect, } from '~/lib/navigation'; -import { buildProjectSyncFeedback } from '~/lib/project-sync-status'; +import { buildProjectSyncErrorFeedback, buildProjectSyncFeedback } from '~/lib/project-sync-status'; import { dedupeSyncedRuns } from '~/lib/run-dedupe'; import type { RunMeta } from '~/lib/types'; type TabId = StudioTabId; @@ -257,16 +257,22 @@ function SingleProjectHome() { queryClient.invalidateQueries({ queryKey: ['remote-status', ''] }), ]); } catch (err) { - setSyncFeedback({ - kind: 'error', - message: (err as Error).message, - }); + setSyncFeedback(buildProjectSyncErrorFeedback(err, remoteStatus)); await queryClient.invalidateQueries({ queryKey: ['remote-status', ''] }); } finally { setSyncInFlight(false); } } + useEffect(() => { + if (syncFeedback?.kind !== 'success') { + return; + } + + const timeout = window.setTimeout(() => setSyncFeedback(null), 7000); + return () => window.clearTimeout(timeout); + }, [syncFeedback]); + return (
diff --git a/apps/dashboard/src/routes/projects/$projectId.tsx b/apps/dashboard/src/routes/projects/$projectId.tsx index a87e05bbd..958059870 100644 --- a/apps/dashboard/src/routes/projects/$projectId.tsx +++ b/apps/dashboard/src/routes/projects/$projectId.tsx @@ -5,7 +5,7 @@ */ import { Link, createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { AnalyticsTab } from '~/components/AnalyticsTab'; @@ -25,7 +25,7 @@ import { useStudioConfig, } from '~/lib/api'; import { resolveProjectDisplayName } from '~/lib/project-display-name'; -import { buildProjectSyncFeedback } from '~/lib/project-sync-status'; +import { buildProjectSyncErrorFeedback, buildProjectSyncFeedback } from '~/lib/project-sync-status'; import { dedupeSyncedRuns } from '~/lib/run-dedupe'; type TabId = 'runs' | 'experiments' | 'analytics' | 'targets'; @@ -160,16 +160,22 @@ function ProjectRunsTab({ queryClient.invalidateQueries({ queryKey: ['remote-status', projectId] }), ]); } catch (err) { - setSyncFeedback({ - kind: 'error', - message: (err as Error).message, - }); + setSyncFeedback(buildProjectSyncErrorFeedback(err, remoteStatus)); await queryClient.invalidateQueries({ queryKey: ['remote-status', projectId] }); } finally { setSyncInFlight(false); } } + useEffect(() => { + if (syncFeedback?.kind !== 'success') { + return; + } + + const timeout = window.setTimeout(() => setSyncFeedback(null), 7000); + return () => window.clearTimeout(timeout); + }, [syncFeedback]); + if (isLoading) { return (
diff --git a/biome.json b/biome.json index 9d2a040d9..4eb1f7896 100644 --- a/biome.json +++ b/biome.json @@ -39,6 +39,7 @@ "**/__tmp_*/**", "**/.agentv/**", ".claude/**", + ".ntm/**", ".opencode/**", ".beads/**", ".ntm/**",