- {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/**",