Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions apps/dashboard/src/components/RunSourceToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {
formatLastSynced,
formatRemoteRunCount,
buildRemoteErrorAction,
buildRemoteStatusItems,
getProjectSyncView,
} from '~/lib/project-sync-status';
import type { RemoteStatusResponse } from '~/lib/types';
Expand Down Expand Up @@ -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 (
<div className="flex flex-col gap-3 rounded-lg border border-gray-800 bg-gray-900/40 p-4">
Expand Down Expand Up @@ -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'}
</button>
) : null}
</div>
Expand All @@ -96,10 +97,9 @@ export function RunSourceToolbar({
{remoteConfigured ? (
<div className="space-y-2 text-sm">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-gray-400">
{projectName ? <span>Project: {projectName}</span> : null}
<span>{formatRemoteRunCount(remoteStatus?.run_count)}</span>
<span>{formatLastSynced(remoteStatus?.last_synced_at)}</span>
{remoteStatus?.repo ? <span>Repo: {remoteStatus.repo}</span> : null}
{remoteStatusItems.map((item) => (
<span key={item}>{item}</span>
))}
</div>
<p className={syncView.tone === 'danger' ? 'text-red-300' : 'text-gray-400'}>
{syncView.summary}
Expand Down Expand Up @@ -132,14 +132,22 @@ export function RunSourceToolbar({
) : null}

{syncFeedback ? (
<div className={`rounded-md border px-3 py-2 text-sm ${feedbackClass}`}>
<div
className={`rounded-md border px-3 py-2 text-sm ${feedbackClass}`}
role={syncFeedback.kind === 'error' ? 'alert' : 'status'}
>
{syncFeedback.message}
</div>
) : null}

{remoteStatus?.last_error ? (
<div className="rounded-md border border-yellow-900/50 bg-yellow-950/20 px-3 py-2 text-sm text-yellow-300">
{remoteStatus.last_error}
<div
className="rounded-md border border-red-900/60 bg-red-950/20 px-3 py-2 text-sm text-red-300"
role="alert"
>
<p className="font-medium">Remote sync error</p>
<p>{remoteStatus.last_error}</p>
<p className="mt-1 text-xs text-red-200/80">{buildRemoteErrorAction(remoteStatus)}</p>
</div>
) : null}

Expand Down
78 changes: 64 additions & 14 deletions apps/dashboard/src/lib/project-sync-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'bun:test';

import {
buildProjectSyncErrorFeedback,
buildProjectSyncFeedback,
buildRemoteStatusItems,
formatRemoteRunCount,
getProjectSyncView,
} from './project-sync-status';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand All @@ -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);
});
});
98 changes: 91 additions & 7 deletions apps/dashboard/src/lib/project-sync-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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)}`,
};
}

Expand All @@ -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)}`,
};
}
16 changes: 11 additions & 5 deletions apps/dashboard/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
Expand Down
18 changes: 12 additions & 6 deletions apps/dashboard/src/routes/projects/$projectId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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 (
<div className="space-y-2">
Expand Down
Loading
Loading