Building a Sync Status Indicator
Aethas indexes local files like Obsidian vaults, or markdown folders for context-aware conversations. This happens at startup, when users manually trigger a re-index, and continuously via file watchers. After some use I found it annoying to guess if a file was synced when I made changes.
This is a small feature, but I thought it would be fun to talk about the design challenge. The sync indicator should be informative but not intrusive. Ideally as I build out some of the later features in my roadmap, we'll see the sync indicator move around and become less prominent.
State Machine Design
I love state machines, so I modeled sync status as a state machine with four states:
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'watching';
| State | Meaning | Visual |
idle | All sources up to date | Green checkmark, "Last sync: 2 min ago" |
syncing | Startup or manual sync in progress | Spinner, "Syncing: Source Name" |
watching | File watcher detected changes | Spinner, "Indexing: filename.md" |
error | Sync completed with errors | Amber warning, "Sync error" |
The transitions:

The key insight: watching is a transient state. When file watchers detect changes, I briefly show indexing activity, then automatically return to idle after 2 seconds of inactivity. This prevents the indicator from flickering during rapid edits while still providing feedback.
The Debounce Pattern
The watching state needs special handling. If a user is actively editing, I don't want the indicator flickering on every keystroke save (the file watches can be that fast).
The solution: a debounce timeout that clears activity after 2 seconds of silence.
setFileIndexed: (sourceId, sourceName, filePath) =>
set((state) => ({
// Only transition to 'watching' if not already in a full sync
status: state.status === 'syncing' ? 'syncing' : 'watching',
watcherActivity: { sourceId, sourceName, filePath },
})),
clearWatcherActivity: () =>
set((state) => ({
status: state.status === 'watching' ? 'idle' : state.status,
watcherActivity: null,
})),
The component resets the timeout on each file event:
if (watcherActivityTimeoutRef.current) {
clearTimeout(watcherActivityTimeoutRef.current);
}
watcherActivityTimeoutRef.current = setTimeout(() => {
clearWatcherActivity();
}, 2000);
User saves a file, indicator shows "Indexing: notes.md", then fades back to idle. User rapid-fires saves while editing, indicator stays on "Indexing" until they pause.
Cross-Platform Events
Aethas runs in two modes: desktop (Tauri) with full file system access, and web connecting to a backend server. The sync status needs to work in both.
Tauri has its own event system: the Rust backend emits events, the frontend subscribes via @tauri-apps/api/event. But that doesn't exist in web mode.
I created an abstraction that mirrors Tauri's API:
export async function listen<T>(
event: string,
callback: (event: { payload: T }) => void
): Promise<() => void> {
if (isTauri()) {
const { listen: tauriListen } = await import('@tauri-apps/api/event');
return tauriListen<T>(event, callback);
} else {
return chatEvents.on<T>(event, (payload) => callback({ payload }));
}
}
Components just call listen() without knowing which mode they're in. The abstraction handles the rest. We'll be making major changes to this in the future, I've already had the need to use this on my phone and other clients. I fully expect to refactor Aethas to make Tauri a thin-client.
Animation Trick
One subtle detail in the UI component. I render all three icons and use CSS to show/hide them:
<Loader2 className={cn('animate-spin', status !== 'syncing' && 'hidden')} />
<AlertTriangle className={cn(status !== 'error' && 'hidden')} />
<Check className={cn((status === 'syncing' || status === 'error') && 'hidden')} />
Why not conditionally render? Because React would unmount and remount the spinner on state changes, restarting the animation from the beginning. Jarring visual jump. There could be a better way to do this, but this one is quite smooth.
Race Condition on Mount
What if the UI mounts after startup sync has already begun? The "started" event already fired. We'd miss it.
I handle this by checking sync status on mount:
useEffect(() => {
isSyncing().then((syncing) => {
if (syncing) {
setStarted({ source_count: 0 });
}
});
}, []);
The Rust backend exposes this via an atomic boolean. Events may fire before your component mounts, always synchronize initial state.
Error UX Philosophy
Errors get collected during sync and displayed after completion. But I intentionally don't show a modal or toast for sync errors.
Why? Sync failures are usually recoverable. File temporarily locked, network hiccup, permission issue that resolves itself. They'll fix on the next sync. Intrusive error UX trains users to ignore notifications.
Instead: the indicator turns amber, the dropdown shows an error count, curious users can expand to see details. I might change this behavior in the future, but for debugging purposes it works pretty well. A resync usually fixes everything.
Key Takeaways
Model sync as a state machine. Explicit states with defined transitions make the logic predictable, debuggable, and testable.
Use transient states for ephemeral activity. The
watchingstate with auto-timeout prevents flicker during rapid changes.Abstract platform differences early. A unified event system means components work identically in Tauri and web modes.
CSS visibility over conditional rendering for animations. Keeps animations smooth across state transitions.
Match error UX to error severity. Not every error needs a modal. Background errors deserve subtle indicators.
Check state on mount. Events may fire before your component mounts. Always synchronize.
Building Aethas in public. Follow along at aethas.ai

