Skip to content

Commit b700d82

Browse files
authored
feat(dashboard): clarify remote sync outcome
Bead: av-ams
1 parent e0cd2e4 commit b700d82

6 files changed

Lines changed: 197 additions & 42 deletions

File tree

apps/dashboard/src/components/RunSourceToolbar.tsx

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
2-
formatLastSynced,
3-
formatRemoteRunCount,
2+
buildRemoteErrorAction,
3+
buildRemoteStatusItems,
44
getProjectSyncView,
55
} from '~/lib/project-sync-status';
66
import type { RemoteStatusResponse } from '~/lib/types';
@@ -44,6 +44,7 @@ export function RunSourceToolbar({
4444
: 'border-red-900/60 bg-red-950/20 text-red-300';
4545
const dirtyPathCount = remoteStatus?.dirty_paths?.length ?? 0;
4646
const conflictedPathCount = remoteStatus?.conflicted_paths?.length ?? 0;
47+
const remoteStatusItems = buildRemoteStatusItems(remoteStatus, projectName);
4748

4849
return (
4950
<div className="flex flex-col gap-3 rounded-lg border border-gray-800 bg-gray-900/40 p-4">
@@ -86,7 +87,7 @@ export function RunSourceToolbar({
8687
title={!syncView.canSync ? syncView.nextAction : undefined}
8788
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"
8889
>
89-
{syncInFlight ? 'Syncing...' : 'Sync Project'}
90+
{syncInFlight ? 'Syncing...' : 'Sync Remote Results'}
9091
</button>
9192
) : null}
9293
</div>
@@ -96,10 +97,9 @@ export function RunSourceToolbar({
9697
{remoteConfigured ? (
9798
<div className="space-y-2 text-sm">
9899
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-gray-400">
99-
{projectName ? <span>Project: {projectName}</span> : null}
100-
<span>{formatRemoteRunCount(remoteStatus?.run_count)}</span>
101-
<span>{formatLastSynced(remoteStatus?.last_synced_at)}</span>
102-
{remoteStatus?.repo ? <span>Repo: {remoteStatus.repo}</span> : null}
100+
{remoteStatusItems.map((item) => (
101+
<span key={item}>{item}</span>
102+
))}
103103
</div>
104104
<p className={syncView.tone === 'danger' ? 'text-red-300' : 'text-gray-400'}>
105105
{syncView.summary}
@@ -132,14 +132,22 @@ export function RunSourceToolbar({
132132
) : null}
133133

134134
{syncFeedback ? (
135-
<div className={`rounded-md border px-3 py-2 text-sm ${feedbackClass}`}>
135+
<div
136+
className={`rounded-md border px-3 py-2 text-sm ${feedbackClass}`}
137+
role={syncFeedback.kind === 'error' ? 'alert' : 'status'}
138+
>
136139
{syncFeedback.message}
137140
</div>
138141
) : null}
139142

140143
{remoteStatus?.last_error ? (
141-
<div className="rounded-md border border-yellow-900/50 bg-yellow-950/20 px-3 py-2 text-sm text-yellow-300">
142-
{remoteStatus.last_error}
144+
<div
145+
className="rounded-md border border-red-900/60 bg-red-950/20 px-3 py-2 text-sm text-red-300"
146+
role="alert"
147+
>
148+
<p className="font-medium">Remote sync error</p>
149+
<p>{remoteStatus.last_error}</p>
150+
<p className="mt-1 text-xs text-red-200/80">{buildRemoteErrorAction(remoteStatus)}</p>
143151
</div>
144152
) : null}
145153

apps/dashboard/src/lib/project-sync-status.test.ts

Lines changed: 64 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import { describe, expect, it } from 'bun:test';
22

33
import {
4+
buildProjectSyncErrorFeedback,
45
buildProjectSyncFeedback,
6+
buildRemoteStatusItems,
57
formatRemoteRunCount,
68
getProjectSyncView,
79
} from './project-sync-status';
@@ -49,20 +51,39 @@ describe('getProjectSyncView', () => {
4951

5052
describe('buildProjectSyncFeedback', () => {
5153
it('summarizes successful sync actions', () => {
52-
expect(
53-
buildProjectSyncFeedback({
54-
configured: true,
55-
available: true,
56-
sync_status: 'clean',
57-
commit_created: true,
58-
pull_performed: true,
59-
push_performed: true,
60-
}),
61-
).toEqual({
62-
kind: 'success',
63-
message:
64-
'Sync complete: committed pending metadata, pulled remote results, pushed local results.',
54+
const feedback = buildProjectSyncFeedback({
55+
configured: true,
56+
available: true,
57+
sync_status: 'clean',
58+
commit_created: true,
59+
pull_performed: true,
60+
push_performed: true,
61+
run_count: 2,
62+
last_synced_at: '2026-06-06T13:00:00.000Z',
6563
});
64+
65+
expect(feedback.kind).toBe('success');
66+
expect(feedback.message).toContain(
67+
'Sync completed: committed pending metadata, pulled remote results, pushed local results.',
68+
);
69+
});
70+
71+
it('confirms WTG-like manual sync with repo, run count, and sync time', () => {
72+
const feedback = buildProjectSyncFeedback({
73+
configured: true,
74+
available: true,
75+
sync_status: 'clean',
76+
repo: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults',
77+
run_count: 1,
78+
last_synced_at: '2026-06-06T13:00:00.000Z',
79+
pull_performed: true,
80+
});
81+
82+
expect(feedback.kind).toBe('success');
83+
expect(feedback.message).toContain('Synced 1 remote run');
84+
expect(feedback.message).toContain('WiseTechGlobal/WTG.AI.Prompts.EvalResults');
85+
expect(feedback.message).toContain(' at ');
86+
expect(feedback.message).toContain('pulled remote results');
6687
});
6788

6889
it('keeps blocked sync feedback explicit', () => {
@@ -76,9 +97,22 @@ describe('buildProjectSyncFeedback', () => {
7697
}),
7798
).toEqual({
7899
kind: 'warning',
79-
message: 'Results repo has unresolved git conflicts',
100+
message:
101+
'Sync stopped: Results repo has unresolved git conflicts. The remote results cache remains available. Resolve the results repo issue, then sync remote results again.',
80102
});
81103
});
104+
105+
it('builds actionable sync failure feedback without hiding cached remote runs', () => {
106+
expect(
107+
buildProjectSyncErrorFeedback(new Error('GitHub authentication failed'), {
108+
configured: true,
109+
available: true,
110+
run_count: 1,
111+
}).message,
112+
).toBe(
113+
'Sync failed: GitHub authentication failed. Cached 1 remote run remains available. Resolve the results repo issue, then sync remote results again.',
114+
);
115+
});
82116
});
83117

84118
describe('formatRemoteRunCount', () => {
@@ -87,3 +121,19 @@ describe('formatRemoteRunCount', () => {
87121
expect(formatRemoteRunCount(2)).toBe('2 remote runs');
88122
});
89123
});
124+
125+
describe('buildRemoteStatusItems', () => {
126+
it('includes WTG-like repo, remote run count, and last sync time', () => {
127+
const items = buildRemoteStatusItems({
128+
configured: true,
129+
available: true,
130+
repo: 'WiseTechGlobal/WTG.AI.Prompts.EvalResults',
131+
run_count: 1,
132+
last_synced_at: '2026-06-06T13:00:00.000Z',
133+
});
134+
135+
expect(items).toContain('1 remote run');
136+
expect(items).toContain('Repo: WiseTechGlobal/WTG.AI.Prompts.EvalResults');
137+
expect(items.some((item) => item.startsWith('Last synced '))).toBe(true);
138+
});
139+
});

apps/dashboard/src/lib/project-sync-status.ts

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,22 @@ export interface ProjectSyncView {
2121
canSync: boolean;
2222
}
2323

24-
export function formatLastSynced(timestamp?: string): string {
24+
function formatTimestamp(timestamp?: string): string | undefined {
2525
if (!timestamp) {
26-
return 'Never synced';
26+
return undefined;
2727
}
2828

2929
const parsed = new Date(timestamp);
3030
if (Number.isNaN(parsed.getTime())) {
31-
return 'Never synced';
31+
return undefined;
3232
}
3333

34-
return `Last synced ${parsed.toLocaleString()}`;
34+
return parsed.toLocaleString();
35+
}
36+
37+
export function formatLastSynced(timestamp?: string): string {
38+
const formatted = formatTimestamp(timestamp);
39+
return formatted ? `Last synced ${formatted}` : 'Never synced';
3540
}
3641

3742
export function formatRemoteRunCount(count?: number): string {
@@ -41,6 +46,22 @@ export function formatRemoteRunCount(count?: number): string {
4146
return `${count} remote run${count === 1 ? '' : 's'}`;
4247
}
4348

49+
export function buildRemoteStatusItems(
50+
status: RemoteStatusResponse | undefined,
51+
projectName?: string,
52+
): string[] {
53+
if (status?.configured !== true) {
54+
return [];
55+
}
56+
57+
return [
58+
projectName ? `Project: ${projectName}` : undefined,
59+
formatRemoteRunCount(status.run_count),
60+
formatLastSynced(status.last_synced_at),
61+
status.repo ? `Repo: ${status.repo}` : undefined,
62+
].filter((item): item is string => item !== undefined);
63+
}
64+
4465
export function getProjectSyncView(
4566
status: RemoteStatusResponse | undefined,
4667
syncInFlight = false,
@@ -140,14 +161,63 @@ export function getProjectSyncView(
140161
};
141162
}
142163

164+
function formatSyncOutcomeRuns(count?: number): string {
165+
if (typeof count !== 'number' || !Number.isFinite(count)) {
166+
return 'remote results';
167+
}
168+
return formatRemoteRunCount(count);
169+
}
170+
171+
function buildSyncOutcomeSentence(status: RemoteStatusResponse): string {
172+
const parts = [`Synced ${formatSyncOutcomeRuns(status.run_count)}`];
173+
if (status.repo) {
174+
parts.push(`from ${status.repo}`);
175+
}
176+
177+
const syncedAt = formatTimestamp(status.last_synced_at);
178+
if (syncedAt) {
179+
parts.push(`at ${syncedAt}`);
180+
}
181+
182+
return `${parts.join(' ')}.`;
183+
}
184+
185+
function buildCachedRemoteAvailability(
186+
status: RemoteStatusResponse | undefined,
187+
): string | undefined {
188+
if (status?.available !== true) {
189+
return undefined;
190+
}
191+
192+
const runCount = status.run_count;
193+
if (typeof runCount === 'number' && Number.isFinite(runCount) && runCount > 0) {
194+
return `Cached ${formatRemoteRunCount(runCount)} ${
195+
runCount === 1 ? 'remains' : 'remain'
196+
} available.`;
197+
}
198+
199+
return 'The remote results cache remains available.';
200+
}
201+
202+
export function buildRemoteErrorAction(status: RemoteStatusResponse | undefined): string {
203+
return [
204+
buildCachedRemoteAvailability(status),
205+
'Resolve the results repo issue, then sync remote results again.',
206+
]
207+
.filter((part): part is string => part !== undefined)
208+
.join(' ');
209+
}
210+
143211
export function buildProjectSyncFeedback(status: RemoteStatusResponse): {
144212
kind: 'success' | 'warning';
145213
message: string;
146214
} {
147215
if (status.blocked || status.sync_status === 'conflicted' || status.sync_status === 'diverged') {
216+
const repo = status.repo ? ` for ${status.repo}` : '';
217+
const reason = status.block_reason ?? 'Sync stopped before changing the results repo.';
148218
return {
149219
kind: 'warning',
150-
message: status.block_reason ?? 'Sync stopped before changing the results repo.',
220+
message: `Sync stopped${repo}: ${reason}. ${buildRemoteErrorAction(status)}`,
151221
};
152222
}
153223

@@ -161,7 +231,21 @@ export function buildProjectSyncFeedback(status: RemoteStatusResponse): {
161231
kind: 'success',
162232
message:
163233
actions.length > 0
164-
? `Sync complete: ${actions.join(', ')}.`
165-
: 'Sync complete; project results are up to date.',
234+
? `${buildSyncOutcomeSentence(status)} Sync completed: ${actions.join(', ')}.`
235+
: `${buildSyncOutcomeSentence(status)} Project results were already up to date.`,
236+
};
237+
}
238+
239+
export function buildProjectSyncErrorFeedback(
240+
error: unknown,
241+
status: RemoteStatusResponse | undefined,
242+
): {
243+
kind: 'error';
244+
message: string;
245+
} {
246+
const message = error instanceof Error ? error.message : String(error);
247+
return {
248+
kind: 'error',
249+
message: `Sync failed: ${message}. ${buildRemoteErrorAction(status)}`,
166250
};
167251
}

apps/dashboard/src/routes/index.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
resolveIndexRoute,
3434
resolveInitialProjectRedirect,
3535
} from '~/lib/navigation';
36-
import { buildProjectSyncFeedback } from '~/lib/project-sync-status';
36+
import { buildProjectSyncErrorFeedback, buildProjectSyncFeedback } from '~/lib/project-sync-status';
3737
import { dedupeSyncedRuns } from '~/lib/run-dedupe';
3838
import type { RunMeta } from '~/lib/types';
3939
type TabId = StudioTabId;
@@ -257,16 +257,22 @@ function SingleProjectHome() {
257257
queryClient.invalidateQueries({ queryKey: ['remote-status', ''] }),
258258
]);
259259
} catch (err) {
260-
setSyncFeedback({
261-
kind: 'error',
262-
message: (err as Error).message,
263-
});
260+
setSyncFeedback(buildProjectSyncErrorFeedback(err, remoteStatus));
264261
await queryClient.invalidateQueries({ queryKey: ['remote-status', ''] });
265262
} finally {
266263
setSyncInFlight(false);
267264
}
268265
}
269266

267+
useEffect(() => {
268+
if (syncFeedback?.kind !== 'success') {
269+
return;
270+
}
271+
272+
const timeout = window.setTimeout(() => setSyncFeedback(null), 7000);
273+
return () => window.clearTimeout(timeout);
274+
}, [syncFeedback]);
275+
270276
return (
271277
<div className="space-y-6">
272278
<div className="flex items-center justify-between">

apps/dashboard/src/routes/projects/$projectId.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
*/
66

77
import { Link, createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router';
8-
import { useState } from 'react';
8+
import { useEffect, useState } from 'react';
99

1010
import { useQuery, useQueryClient } from '@tanstack/react-query';
1111
import { AnalyticsTab } from '~/components/AnalyticsTab';
@@ -25,7 +25,7 @@ import {
2525
useStudioConfig,
2626
} from '~/lib/api';
2727
import { resolveProjectDisplayName } from '~/lib/project-display-name';
28-
import { buildProjectSyncFeedback } from '~/lib/project-sync-status';
28+
import { buildProjectSyncErrorFeedback, buildProjectSyncFeedback } from '~/lib/project-sync-status';
2929
import { dedupeSyncedRuns } from '~/lib/run-dedupe';
3030

3131
type TabId = 'runs' | 'experiments' | 'analytics' | 'targets';
@@ -160,16 +160,22 @@ function ProjectRunsTab({
160160
queryClient.invalidateQueries({ queryKey: ['remote-status', projectId] }),
161161
]);
162162
} catch (err) {
163-
setSyncFeedback({
164-
kind: 'error',
165-
message: (err as Error).message,
166-
});
163+
setSyncFeedback(buildProjectSyncErrorFeedback(err, remoteStatus));
167164
await queryClient.invalidateQueries({ queryKey: ['remote-status', projectId] });
168165
} finally {
169166
setSyncInFlight(false);
170167
}
171168
}
172169

170+
useEffect(() => {
171+
if (syncFeedback?.kind !== 'success') {
172+
return;
173+
}
174+
175+
const timeout = window.setTimeout(() => setSyncFeedback(null), 7000);
176+
return () => window.clearTimeout(timeout);
177+
}, [syncFeedback]);
178+
173179
if (isLoading) {
174180
return (
175181
<div className="space-y-2">

0 commit comments

Comments
 (0)