Unibox
SyncProgressPanel
The dialog behind the Unibox sync button.
Guidelines#
It shows what is importing, how far along it is and what it has done, so nobody stares at a spinner wondering whether the tab can close.
Use it for watching a mailbox, LinkedIn or WhatsApp import.
What you provide
open,onOpenChangeandisSyncing.snapshot: aSyncProgressSnapshotwithaccounts(each with aphase, counters,last_errorandsince_heartbeat_ms),summary(total,running,settled,failed,messages_imported,percent) andobserved_at.log:SyncLogEntry[], each{ id, at, level, account?, message }.elapsedMs,onDetachandonSyncAgain.
Anatomy
- The dialog is
sm:max-w-2xl, capped atmax-h-[85vh], headed "Syncing 2 of 4 accounts". - The overall bar counts accounts, not messages. Providers never say how much history is left.
- Account rows are
rounded-md border bg-card px-3 py-2, ordered running, failed, queued, idle, done.- The spinner is
primary, oramber-500after 3 minutes without a heartbeat. - Running rows sweep a
h-1bar (animate-unibox-sync-sweep, 1.4s) abovetext-[11px]counters. - Failed rows state the error in plain words.
- The spinner is
- Activity is a
h-40monospacetext-[11px]log that follows new lines. Info is muted, successemerald-600, warnamber-600, error destructive. - The footer offers "Continue in background" while syncing, or "Close" and "Sync again" afterwards.
Don't invent a message percentage or hide disconnected accounts. They stay in the total, so the bar stops short of 100%.
Facts#
| Group | Unibox |
|---|---|
| Product | Max |
| Global | window.DigitalCrew.SyncProgressPanel |
| Source | max-agent: src/features/unibox/ui/components/sync-progress-panel.tsx |
| Import in the app | import { SyncProgressPanel } from "@/features/unibox/ui/components/sync-progress-panel"; |
API#
TypeScript declarations emitted from src/features/unibox/ui/components/sync-progress-panel.tsx.
interface SyncProgressPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
isSyncing: boolean;
snapshot: SyncProgressSnapshot | null;
log: SyncLogEntry[];
elapsedMs: number;
/** Stop issuing passes and leave the rest to the background import. */
onDetach: () => void;
/** Start another run (shown once the current one has finished). */
onSyncAgain: () => void;
}
export declare function SyncProgressPanel({ open, onOpenChange, isSyncing, snapshot, log, elapsedMs, onDetach, onSyncAgain, }: SyncProgressPanelProps): JSX.Element;Example#
The code of the preview above: plain React.createElement against window.DigitalCrew, with realistic data.
<div id="root" class="p-6"></div>
<script>
(function () {
var h = React.createElement, DC = window.DigitalCrew, I = DC.Icons;
var NOW = Date.now(), SEC = 1000;
function iso(msAgo) { return new Date(NOW - msAgo).toISOString(); }
function acct(o) {
return Object.assign({
account_status: "connected", backfilled: false, resumable: false, pages_processed: 0, messages_seen: 0,
messages_imported: 0, chats_created: 0, attachments_seen: 0, last_error: null,
started_at: iso(94 * SEC), finished_at: null, heartbeat_at: null, since_heartbeat_ms: null
}, o);
}
var ACCOUNTS = [
acct({ account_id: "a1", name: null, email: "mathieu@digitalcrew.ai", channel: "email", phase: "importing", resumable: true,
pages_processed: 38, messages_seen: 2140, messages_imported: 1876, chats_created: 212, attachments_seen: 94,
heartbeat_at: iso(4 * SEC), since_heartbeat_ms: 4000 }),
acct({ account_id: "a2", name: "Mathieu Laurent", email: null, channel: "linkedin", phase: "catching-up", backfilled: true,
pages_processed: 3, messages_seen: 48, messages_imported: 42, chats_created: 6, heartbeat_at: iso(2 * SEC), since_heartbeat_ms: 2000 }),
acct({ account_id: "a3", name: "Sales team inbox", email: "sales@digitalcrew.ai", channel: "email", phase: "done", backfilled: true,
pages_processed: 71, messages_seen: 3530, messages_imported: 3412, chats_created: 408, attachments_seen: 157, finished_at: iso(23 * SEC) }),
acct({ account_id: "a4", name: "WhatsApp Business", email: null, channel: "whatsapp", phase: "failed", pages_processed: 2,
messages_seen: 0, messages_imported: 0, finished_at: iso(54 * SEC),
last_error: 'Unipile API error: 429 - {"status":429,"type":"errors/too_many_requests","title":"Too many requests"}' })
];
var SNAPSHOT = {
accounts: ACCOUNTS,
summary: { total: 4, running: 2, settled: 1, failed: 1, messages_imported: 5330, chats_created: 626, percent: 50 },
observed_at: new Date(NOW).toISOString()
};
function line(id, sAgo, level, message, account, channel) {
return { id: id, at: NOW - sAgo * SEC, level: level, message: message, account: account, channel: channel, account_id: account ? account : undefined };
}
var LOG = [
line("log-0", 94, "info", "Starting import across all connected accounts…"),
line("log-1", 92, "info", "Resuming the interrupted import", "mathieu@digitalcrew.ai", "email"),
line("log-2", 92, "info", "Checking for new messages", "Mathieu Laurent", "linkedin"),
line("log-3", 92, "info", "Importing history", "Sales team inbox", "email"),
line("log-4", 54, "error", "The provider rate-limited us. The import resumes on its own.", "WhatsApp Business", "whatsapp"),
line("log-5", 47, "info", "+1,204 messages · 27 pages (1,204 messages so far)", "mathieu@digitalcrew.ai", "email"),
line("log-6", 23, "success", "Done — imported 3,412 messages", "Sales team inbox", "email"),
line("log-7", 6, "info", "+672 messages · 11 pages (1,876 messages so far)", "mathieu@digitalcrew.ai", "email"),
line("log-8", 4, "info", "More history to pull — starting another pass.")
];
function App() {
var o = React.useState(true), open = o[0], setOpen = o[1];
var e = React.useState(94 * SEC), elapsed = e[0], setElapsed = e[1];
React.useEffect(function () {
// The panel takes focus on open; drop it so the still shows no focus ring.
var t = setTimeout(function () { if (document.activeElement) document.activeElement.blur(); }, 60);
return function () { clearTimeout(t); };
}, []);
React.useEffect(function () {
var id = setInterval(function () { setElapsed(function (x) { return x + SEC; }); }, SEC);
return function () { clearInterval(id); };
}, []);
return h("div", { className: "flex items-center justify-between rounded-xl border border-border/60 bg-card px-4 py-3" },
h("div", { className: "text-sm font-medium" }, "Inbox"),
h(DC.Button, { variant: "outline", size: "sm", onClick: function () { setOpen(true); } }, h(I.Loader2, { className: "animate-spin" }), "Syncing…"),
h(DC.SyncProgressPanel, {
open: open, onOpenChange: setOpen, isSyncing: true, snapshot: SNAPSHOT, log: LOG, elapsedMs: elapsed,
onDetach: function () {}, onSyncAgain: function () {}
}));
}
ReactDOM.createRoot(document.getElementById("root")).render(h(DC.TooltipProvider, { delayDuration: 80 }, h(App)));
})();
</script>