Skip to content

Commit ced34dc

Browse files
authored
Dashboard loading states (#61)
1 parent 8b47c67 commit ced34dc

4 files changed

Lines changed: 159 additions & 9 deletions

File tree

src/components/sections/DashboardWorkspace.jsx

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,20 @@ const EXAMPLE_PROMPTS = [
102102
{ icon: Layers, title: 'Business overview', prompt: 'Build a dashboard with the most important KPIs and a couple of charts summarizing overall business health.' },
103103
]
104104

105+
// Rotates under the real live step while a build is in flight — genuine,
106+
// shippped-feature facts (not filler), so the wait teaches something instead
107+
// of just decorating it. Kept short; the real step above stays the primary line.
108+
const BUILD_TIPS = [
109+
'Every query is verified against your real data before it ever reaches the screen.',
110+
'Dashboards run read-only — the agent can look, never write.',
111+
'Ask to add a chart or change a metric any time — it edits in place.',
112+
'Every save keeps a version history, so you can always roll back a change.',
113+
'Publish a dashboard to get a read-only link — no login required to view it.',
114+
'Turn any dashboard into a TV/kiosk view that auto-refreshes on a wall display.',
115+
'Set a plain-English alert and DeepSQL checks it on a schedule for you.',
116+
'Duplicate a dashboard to branch it without touching the original.',
117+
]
118+
105119
// Focused, chrome-less builder. Left = the agent (generate / refine); main = the
106120
// live dashboard canvas. The DeepSQL logo and breadcrumb return to the gallery.
107121
//
@@ -234,6 +248,17 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
234248

235249
useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight }, [messages, thinking])
236250

251+
// Rotates BUILD_TIPS while the main canvas shows the "Building…" state, so a
252+
// 15-30s wait has something changing on screen beyond the elapsed-time
253+
// ticker. Starts at a random index (not always the same first tip) and
254+
// resets once the build ends so the next one starts fresh.
255+
const [tipIndex, setTipIndex] = useState(() => Math.floor(Math.random() * BUILD_TIPS.length))
256+
useEffect(() => {
257+
if (!thinking) return undefined
258+
const id = setInterval(() => setTipIndex((i) => (i + 1) % BUILD_TIPS.length), 3500)
259+
return () => clearInterval(id)
260+
}, [thinking])
261+
237262
const refreshNow = useCallback(() => {
238263
artifactRef.current?.reload()
239264
setLastRefreshedAt(Date.now())
@@ -891,17 +916,25 @@ export default function DashboardWorkspace({ connectionId, dashboard, onClose })
891916
)}
892917
</div>
893918
</>
894-
) : (
919+
) : thinking ? (
895920
<div className={styles.newCanvas}>
896921
<div className={styles.newCanvasInner}>
897-
<span className={styles.newIcon}><Sparkles size={22} color="#534AB7" /></span>
898-
<h2 className={styles.newTitle}>{thinking ? 'Building your dashboard…' : 'Nothing built here yet'}</h2>
922+
<span className={styles.newIconPulse}><Sparkles size={22} color="#534AB7" /></span>
923+
<h2 className={styles.newTitle}>Building your dashboard…</h2>
899924
<p className={styles.newSub}>
900-
{thinking
901-
? 'The agent is grounding on your schema and verifying every query — this usually takes a bit.'
902-
: 'This dashboard doesn’t have a build yet — the chat on the left may just be planning so far. Ask for a chart to get started.'}
925+
{steps.length > 0 ? steps[steps.length - 1].message : 'Grounding on your schema and verifying every query…'}
926+
<span className={styles.buildElapsed}> · {elapsed}s</span>
903927
</p>
904-
{!thinking && <button className={styles.backLink} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>}
928+
<p key={tipIndex} className={styles.buildTip}>{BUILD_TIPS[tipIndex]}</p>
929+
</div>
930+
</div>
931+
) : (
932+
<div className={styles.newCanvas}>
933+
<div className={styles.newCanvasInner}>
934+
<span className={styles.newIcon}><Sparkles size={22} color="#534AB7" /></span>
935+
<h2 className={styles.newTitle}>Nothing built here yet</h2>
936+
<p className={styles.newSub}>This dashboard doesn’t have a build yet — the chat on the left may just be planning so far. Ask for a chart to get started.</p>
937+
<button className={styles.backLink} onClick={onClose}><ChevronLeft size={14} /> Back to dashboards</button>
905938
</div>
906939
</div>
907940
)}

src/components/sections/DashboardWorkspace.module.css

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,9 +1243,58 @@
12431243
box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08);
12441244
}
12451245

1246+
/* Same shape as .newIcon, but alive while a build is in flight — a static
1247+
sparkle here reads as frozen given the sidebar's own trace is animating. */
1248+
.newIconPulse {
1249+
width: 52px;
1250+
height: 52px;
1251+
border-radius: 14px;
1252+
background: #EEEDFE;
1253+
display: flex;
1254+
align-items: center;
1255+
justify-content: center;
1256+
margin-bottom: 4px;
1257+
box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08);
1258+
animation: dsqlBuildPulse 1.8s ease-in-out infinite;
1259+
}
1260+
1261+
@keyframes dsqlBuildPulse {
1262+
0%, 100% { transform: scale(1); box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08), 0 0 0 0 rgba(83, 74, 183, 0.18); }
1263+
50% { transform: scale(1.06); box-shadow: 0 1px 2px rgba(83, 74, 183, 0.08), 0 0 0 10px rgba(83, 74, 183, 0); }
1264+
}
1265+
1266+
@media (prefers-reduced-motion: reduce) {
1267+
.newIconPulse { animation: none; }
1268+
}
1269+
12461270
.newTitle { font-size: 19px; font-weight: 650; letter-spacing: -0.014em; color: #111; margin: 0; }
12471271
.newSub { font-size: 13px; color: #777; max-width: 380px; line-height: 1.6; margin: 0; }
12481272

1273+
.buildElapsed { color: #a3a3a3; font-variant-numeric: tabular-nums; }
1274+
1275+
/* Rotates every few seconds (see BUILD_TIPS) — the `key={tipIndex}` on the
1276+
element itself retriggers this fade-in each time the tip text changes. */
1277+
.buildTip {
1278+
margin: 18px 0 0;
1279+
padding: 10px 16px;
1280+
max-width: 420px;
1281+
font-size: 12.5px;
1282+
line-height: 1.55;
1283+
color: #534AB7;
1284+
background: #F5F3FF;
1285+
border-radius: 10px;
1286+
animation: dsqlTipIn 320ms cubic-bezier(0.2, 0.8, 0.2, 1);
1287+
}
1288+
1289+
@keyframes dsqlTipIn {
1290+
from { opacity: 0; transform: translateY(4px); }
1291+
to { opacity: 1; transform: none; }
1292+
}
1293+
1294+
@media (prefers-reduced-motion: reduce) {
1295+
.buildTip { animation: none; }
1296+
}
1297+
12491298
.exampleGrid {
12501299
display: grid;
12511300
grid-template-columns: repeat(2, minmax(0, 1fr));

src/components/sections/DashboardsHome.jsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,19 @@ function Thumb({ id }) {
5050
)
5151
}
5252

53+
// Placeholder count for the initial-load skeleton — enough to fill a typical
54+
// viewport width without over-committing to a specific grid density.
55+
const SKELETON_COUNT = 8
56+
5357
export default function DashboardsHome({ connectionId, onOpen }) {
5458
const [dashboards, setDashboards] = useState([])
55-
const [loading, setLoading] = useState(false)
59+
// Starts true (not false) so the very first render — before the fetch effect
60+
// has even run — shows the loading skeleton, not the "No dashboards yet"
61+
// empty state. Without this, dashboards.length === 0 && !loading was
62+
// momentarily true on every mount, flashing the empty state before the real
63+
// list (or genuine empty state) landed a beat later.
64+
const [loading, setLoading] = useState(true)
65+
const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
5666
const [filter, setFilter] = useState('all')
5767
const [folder, setFolder] = useState(null) // null = all folders
5868
const [search, setSearch] = useState('')
@@ -161,9 +171,14 @@ export default function DashboardsHome({ connectionId, onOpen }) {
161171
setDashboards([])
162172
} finally {
163173
setLoading(false)
174+
setHasLoadedOnce(true)
164175
}
165176
}, [connectionId])
166177

178+
// Switching connections must re-show the skeleton, not the previous
179+
// connection's cards (or a premature "no dashboards" for the new one) while
180+
// the new fetch is in flight.
181+
useEffect(() => { setHasLoadedOnce(false); setLoading(true) }, [connectionId])
167182
useEffect(() => { load() }, [load])
168183

169184
const folders = useMemo(() => {
@@ -241,7 +256,19 @@ export default function DashboardsHome({ connectionId, onOpen }) {
241256
))}
242257
</div>
243258

244-
{!loading && dashboards.length === 0 ? (
259+
{!hasLoadedOnce ? (
260+
<div className={styles.grid}>
261+
{Array.from({ length: SKELETON_COUNT }, (_, i) => (
262+
<div key={i} className={styles.skeletonCard}>
263+
<div className={styles.skeletonThumb} />
264+
<div className={styles.skeletonBody}>
265+
<div className={styles.skeletonLine} style={{ width: '60%' }} />
266+
<div className={styles.skeletonLine} style={{ width: '35%' }} />
267+
</div>
268+
</div>
269+
))}
270+
</div>
271+
) : dashboards.length === 0 ? (
245272
<div className={styles.emptyState}>
246273
<span className={styles.emptyIcon}><LayoutDashboard size={22} /></span>
247274
<h2 className={styles.emptyTitle}>No dashboards yet</h2>

src/components/sections/DashboardsHome.module.css

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,47 @@
398398
color: #999;
399399
}
400400

401+
/* Initial-load placeholder — mirrors .card's shape (thumb + body) so the grid
402+
doesn't jump when real cards replace these. */
403+
.skeletonCard {
404+
display: flex;
405+
flex-direction: column;
406+
border: 1px solid #ececec;
407+
border-radius: 14px;
408+
background: #fff;
409+
overflow: hidden;
410+
}
411+
412+
.skeletonThumb {
413+
height: 108px;
414+
background: linear-gradient(180deg, #fbfaff 0%, #fafafa 100%);
415+
border-bottom: 1px solid #f0f0f0;
416+
}
417+
418+
.skeletonBody {
419+
display: flex;
420+
flex-direction: column;
421+
gap: 8px;
422+
padding: 14px;
423+
}
424+
425+
.skeletonLine {
426+
height: 11px;
427+
border-radius: 5px;
428+
background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%);
429+
background-size: 200% 100%;
430+
animation: dsqlshimmer 1.4s ease-in-out infinite;
431+
}
432+
433+
@keyframes dsqlshimmer {
434+
from { background-position: 200% 0; }
435+
to { background-position: -200% 0; }
436+
}
437+
438+
@media (prefers-reduced-motion: reduce) {
439+
.skeletonLine { animation: none; background: #eee; }
440+
}
441+
401442
.ctaCard {
402443
display: flex;
403444
flex-direction: column;

0 commit comments

Comments
 (0)