diff --git a/assets/stylesheets/App.css b/assets/stylesheets/App.css
index ac27a02f..01677eec 100644
--- a/assets/stylesheets/App.css
+++ b/assets/stylesheets/App.css
@@ -4208,6 +4208,142 @@ body.objectKanbanColumnResizing * {
stroke-width: 12px;
}
+.dashboard-navigation-activity-cluster {
+ gap: 0;
+ transition: gap 0.3s cubic-bezier(0.2, 0, 0, 1);
+}
+
+.dashboard-navigation-activity-cluster:has(.live-activities-open) {
+ gap: var(--dashboard-navigation-gap, 8px);
+}
+
+.live-activities {
+ overflow: hidden;
+ border-radius: 12px;
+ flex: 0 0 auto;
+ transition:
+ width 0.3s cubic-bezier(0.2, 0, 0, 1),
+ opacity 0.3s cubic-bezier(0.2, 0, 0, 1);
+}
+
+.live-activities-row {
+ display: flex;
+ align-items: stretch;
+ height: 100%;
+ width: max-content;
+}
+
+.live-activity-slot {
+ overflow: hidden;
+ flex: 0 0 auto;
+ height: 100%;
+ position: relative;
+ transition: width 0.3s cubic-bezier(0.2, 0, 0, 1);
+}
+
+.live-activity {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ overflow: hidden;
+ cursor: pointer;
+ line-height: 1;
+ container-type: inline-size;
+ background: color-mix(in srgb, var(--live-activity-color) 20%, transparent);
+ transition: background 0.2s ease;
+}
+
+.live-activity:hover {
+ background: color-mix(in srgb, var(--live-activity-color) 35%, transparent);
+}
+
+.live-activity-progress,
+.live-activity-flash {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ pointer-events: none;
+}
+
+.live-activity-flash {
+ right: 0;
+}
+
+.live-activity-progress {
+ overflow: hidden;
+}
+
+.live-activity-progress-fill {
+ position: absolute;
+ inset: 0;
+ opacity: 0.22;
+ transition: opacity 0.2s ease;
+}
+
+.live-activity:hover .live-activity-progress-fill {
+ opacity: 0.4;
+}
+
+.live-activity-progress-wave {
+ position: absolute;
+ inset-block: 0;
+ inset-inline-start: -80cqw;
+ width: 240cqw;
+ background: repeating-linear-gradient(
+ 90deg,
+ rgba(255, 255, 255, 0) 0,
+ rgba(255, 255, 255, 0.05) 40cqw,
+ rgba(255, 255, 255, 0) 80cqw
+ );
+ animation: live-activity-progress-wave 1s linear infinite;
+}
+
+@keyframes live-activity-progress-wave {
+ 0% {
+ transform: translateX(0);
+ }
+ 100% {
+ transform: translateX(calc(100% / 3));
+ }
+}
+
+.live-activity-icon {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ line-height: 1;
+ color: var(--color-text);
+}
+
+@keyframes live-activity-flash {
+ 0%,
+ 100% {
+ opacity: var(--live-activity-flash-from);
+ }
+ 50% {
+ opacity: var(--live-activity-flash-to);
+ }
+}
+
+.live-activity-flash {
+ --live-activity-flash-from: 0;
+ --live-activity-flash-to: 0.2;
+ animation: live-activity-flash 2s ease-in-out infinite;
+}
+
+.live-activity:hover .live-activity-flash {
+ --live-activity-flash-from: 0.12;
+ --live-activity-flash-to: 0.45;
+}
+
.printerQueue.ant-steps.ant-steps-vertical
> .ant-steps-item:last-child
.ant-steps-item-content {
diff --git a/src/components/Dashboard/common/DashboardNavigation.jsx b/src/components/Dashboard/common/DashboardNavigation.jsx
index 35efee2f..f640c00d 100644
--- a/src/components/Dashboard/common/DashboardNavigation.jsx
+++ b/src/components/Dashboard/common/DashboardNavigation.jsx
@@ -17,7 +17,8 @@ import {
Badge,
Divider,
Typography,
- Popover
+ Popover,
+ theme
} from 'antd'
import classNames from 'classnames'
import { AuthContext } from '../context/AuthContext'
@@ -46,6 +47,7 @@ import SettingsIcon from '../../Icons/SettingsIcon'
import { ElectronContext } from '../context/ElectronContext'
import DashboardWindowButtons from './DashboardWindowButtons'
import DashboardDevControls from './DashboardDevControls'
+import LiveActivities from './LiveActivities'
import WindowAppMenu from './WindowAppMenu'
import WebAppSwitcher from './WebAppSwitcher'
import DashboardTabs from './DashboardTabs'
@@ -148,6 +150,8 @@ const DashboardNavigationTrailingItems = memo(
onNotifications,
onCheckUpdates
}) {
+ const { token } = theme.useToken()
+
return (
{controlsReady ? (
@@ -165,25 +169,34 @@ const DashboardNavigationTrailingItems = memo(
onClick={onSearch}
/>
-
-
+
- }
- type='text'
- style={NAV_BELL_BUTTON_STYLE}
- onClick={onNotifications}
- />
-
-
+
+ }
+ type='text'
+ style={NAV_BELL_BUTTON_STYLE}
+ onClick={onNotifications}
+ />
+
+
+
) : null}
diff --git a/src/components/Dashboard/common/LiveActivities.jsx b/src/components/Dashboard/common/LiveActivities.jsx
new file mode 100644
index 00000000..9a5c98a1
--- /dev/null
+++ b/src/components/Dashboard/common/LiveActivities.jsx
@@ -0,0 +1,450 @@
+import {
+ createElement,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState
+} from 'react'
+import { theme, Popover } from 'antd'
+import { useNavigate } from 'react-router-dom'
+import { ApiServerContext } from '../context/ApiServerContext'
+import { AuthContext } from '../context/AuthContext'
+import { getModelByName } from '../../../database/ObjectModels'
+import { getStateTagInfo } from '../utils/Utils'
+import SpotlightTooltip from './SpotlightTooltip'
+
+const ACTIVITY_TYPES = new Set([
+ 'printing',
+ 'processing',
+ 'deploying',
+ 'loading',
+ 'queued',
+ 'syncing',
+ 'publishing',
+ 'unpublishing',
+ 'initializing',
+ 'rendering',
+ 'sending',
+ 'connecting',
+ 'downloading',
+ 'startup',
+ 'paused',
+ 'used'
+])
+
+const PROGRESS_TYPES = new Set([
+ 'loading',
+ 'processing',
+ 'queued',
+ 'printing',
+ 'used',
+ 'deploying',
+ 'syncing',
+ 'publishing',
+ 'unpublishing'
+])
+
+const STATUS_COLOR_VAR = {
+ success: '--color-success',
+ warning: '--color-warning',
+ error: '--color-error',
+ processing: '--color-info',
+ info: '--color-info',
+ default: '--color-info',
+ cyan: '--color-cyan',
+ purple: '--color-purple',
+ magenta: '--color-magenta',
+ volcano: '--color-volcano',
+ pink: '--color-pink'
+}
+
+const LIVE_ACTIVITY_TRANSITION_MS = 300
+
+const readState = (object) => {
+ const raw = object?.state ?? object?.status
+ if (raw && typeof raw === 'object') {
+ return {
+ type: raw.type,
+ progress: raw.progress,
+ message: raw.message
+ }
+ }
+ if (typeof raw === 'string') {
+ return { type: raw }
+ }
+ return null
+}
+
+const hasStateField = (object) => {
+ if (!object || typeof object !== 'object') return false
+ return object.state != null || object.status != null
+}
+
+const progressPercent = (state) => {
+ if (!state || !PROGRESS_TYPES.has(state.type)) return null
+ const progress = Number(state.progress)
+ if (!Number.isFinite(progress) || progress <= 0) return null
+ const percent =
+ progress <= 1
+ ? Math.round(progress * 1000) / 10
+ : Math.round(progress * 10) / 10
+ if (percent <= 0 || percent >= 100) return null
+ return percent
+}
+
+const objectIdOf = (value) => {
+ if (!value) return null
+ if (typeof value === 'string') return value
+ return value._id ?? null
+}
+
+const mergeObjectPatch = (current, patch) => {
+ if (!patch || typeof patch !== 'object') return current
+ const next = { ...(current || {}), ...patch }
+ if (
+ patch.state &&
+ typeof patch.state === 'object' &&
+ current?.state &&
+ typeof current.state === 'object'
+ ) {
+ next.state = { ...current.state, ...patch.state }
+ }
+ if (
+ patch.status &&
+ typeof patch.status === 'object' &&
+ current?.status &&
+ typeof current.status === 'object'
+ ) {
+ next.status = { ...current.status, ...patch.status }
+ }
+ return next
+}
+
+const LiveActivities = () => {
+ const { token } = theme.useToken()
+ const navigate = useNavigate()
+ const {
+ userProfile,
+ token: authToken,
+ authInitialized
+ } = useContext(AuthContext)
+ const {
+ fetchObjects,
+ connected,
+ subscribeToObjectTypeUpdates,
+ subscribeToObjectUpdates
+ } = useContext(ApiServerContext)
+ const [watched, setWatched] = useState([])
+
+ const userId = userProfile?._id
+ const authReady = Boolean(authToken && authInitialized && userId)
+
+ const apiRef = useRef({ fetchObjects })
+ apiRef.current = { fetchObjects }
+ const watchedRef = useRef(watched)
+ watchedRef.current = watched
+
+ const loadNotifiers = useCallback(async () => {
+ if (!userId) {
+ setWatched([])
+ return
+ }
+
+ const result = await apiRef.current.fetchObjects('userNotifier', {
+ filter: { user: userId },
+ limit: 200
+ })
+ const notifiers = Array.isArray(result?.data)
+ ? result.data
+ : Array.isArray(result)
+ ? result
+ : []
+
+ setWatched(
+ notifiers
+ .map((notifier) => {
+ const objectId = objectIdOf(notifier?.object)
+ if (!objectId || !notifier?.objectType) return null
+ const object =
+ notifier.object && typeof notifier.object === 'object'
+ ? notifier.object
+ : { _id: objectId }
+ return {
+ notifierId: notifier._id,
+ objectType: notifier.objectType,
+ object
+ }
+ })
+ .filter(Boolean)
+ )
+ }, [userId])
+
+ const loadNotifiersRef = useRef(loadNotifiers)
+ loadNotifiersRef.current = loadNotifiers
+
+ useEffect(() => {
+ if (!authReady || !connected) {
+ if (!authReady) setWatched([])
+ return undefined
+ }
+ loadNotifiersRef.current()
+ return undefined
+ }, [authReady, connected])
+
+ useEffect(() => {
+ if (!authReady || connected !== true || !userId) return undefined
+ const unsubscribe = subscribeToObjectTypeUpdates(
+ 'userNotifier',
+ { user: userId },
+ () => {
+ loadNotifiersRef.current()
+ }
+ )
+ return () => unsubscribe?.()
+ }, [authReady, connected, subscribeToObjectTypeUpdates, userId])
+
+ const subscriptionKey = useMemo(
+ () =>
+ watched
+ .filter((item) => hasStateField(item.object))
+ .map(
+ (item) =>
+ `${item.objectType}:${String(item.object._id).toLowerCase()}`
+ )
+ .sort()
+ .join('|'),
+ [watched]
+ )
+
+ useEffect(() => {
+ if (connected !== true || !subscriptionKey) return undefined
+
+ const unsubscribes = watchedRef.current
+ .filter((item) => hasStateField(item.object))
+ .map((item) => {
+ const id = String(item.object._id).toLowerCase()
+ return subscribeToObjectUpdates(id, item.objectType, (patch) => {
+ setWatched((prev) =>
+ prev.map((entry) => {
+ const entryId = entry.object?._id
+ if (!entryId || String(entryId).toLowerCase() !== id) return entry
+ if (entry.objectType !== item.objectType) return entry
+ return {
+ ...entry,
+ object: mergeObjectPatch(entry.object, patch)
+ }
+ })
+ )
+ })
+ })
+
+ return () => {
+ unsubscribes.forEach((unsubscribe) => unsubscribe?.())
+ }
+ }, [connected, subscriptionKey, subscribeToObjectUpdates])
+
+ const activities = useMemo(
+ () =>
+ watched.flatMap((item) => {
+ const state = readState(item.object)
+ if (!state?.type || !ACTIVITY_TYPES.has(state.type)) return []
+ const model = getModelByName(item.objectType)
+ const { status, text } = getStateTagInfo(state.type)
+ const percent = progressPercent(state)
+ const name = item.object?.name || model.label
+ return [
+ {
+ key: `${item.objectType}:${item.object._id}`,
+ id: item.object._id,
+ name,
+ model,
+ status,
+ text,
+ percent
+ }
+ ]
+ }),
+ [watched]
+ )
+
+ const [slots, setSlots] = useState([])
+ const activityKeysRef = useRef(new Set())
+ activityKeysRef.current = new Set(activities.map((activity) => activity.key))
+
+ useEffect(() => {
+ setSlots((prev) => {
+ const seen = new Set()
+ const next = []
+
+ for (const slot of prev) {
+ const fresh = activities.find((activity) => activity.key === slot.key)
+ if (fresh) {
+ next.push({ ...fresh, open: slot.open })
+ seen.add(slot.key)
+ } else {
+ next.push({ ...slot, open: false })
+ }
+ }
+
+ for (const activity of activities) {
+ if (seen.has(activity.key)) continue
+ next.push({ ...activity, open: false })
+ }
+
+ return next
+ })
+ }, [activities])
+
+ useEffect(() => {
+ const needsOpen = slots.some(
+ (slot) => !slot.open && activityKeysRef.current.has(slot.key)
+ )
+ if (!needsOpen) return undefined
+
+ const frame = window.requestAnimationFrame(() => {
+ setSlots((current) => {
+ const live = activityKeysRef.current
+ let changed = false
+ const next = current.map((slot) => {
+ if (!slot.open && live.has(slot.key)) {
+ changed = true
+ return { ...slot, open: true }
+ }
+ return slot
+ })
+ return changed ? next : current
+ })
+ })
+
+ return () => window.cancelAnimationFrame(frame)
+ }, [slots])
+
+ useEffect(() => {
+ const closing = slots.some(
+ (slot) => !slot.open && !activityKeysRef.current.has(slot.key)
+ )
+ if (!closing) return undefined
+
+ const timeoutId = window.setTimeout(() => {
+ setSlots((current) =>
+ current.filter(
+ (slot) => slot.open || activityKeysRef.current.has(slot.key)
+ )
+ )
+ }, LIVE_ACTIVITY_TRANSITION_MS + 40)
+
+ return () => window.clearTimeout(timeoutId)
+ }, [slots])
+
+ const size = token.controlHeight
+ const iconSize = Math.max(12, Math.round(size * 0.5))
+ const openCount = slots.filter((slot) => slot.open).length
+ const activitiesOpen = openCount > 0
+
+ return (
+
+
+ {slots.map((activity) => {
+ const colorVar = STATUS_COLOR_VAR[activity.status] || '--color-info'
+ const color = `var(${colorVar})`
+ const tooltip =
+ activity.percent == null
+ ? `${activity.name} · ${activity.text}`
+ : `${activity.name} · ${activity.text} ${activity.percent}%`
+ const open = () => {
+ if (!activity.open || typeof activity.model.url !== 'string') return
+ navigate(
+ `${activity.model.url}/info?${activity.model.name}Id=${activity.id}`
+ )
+ }
+
+ return (
+
+ }
+ trigger='hover'
+ placement='bottomRight'
+ arrow={false}
+ style={{ padding: 0 }}
+ >
+
{
+ if (event.target !== event.currentTarget) return
+ if (event.propertyName !== 'width') return
+ if (activity.open) return
+ setSlots((current) =>
+ current.filter((slot) => slot.key !== activity.key)
+ )
+ }}
+ >
+
+
+
+ )
+ })}
+
+
+ )
+}
+
+export default LiveActivities
diff --git a/vite.config.js b/vite.config.js
index 276089ce..085c0a6c 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -50,12 +50,29 @@ export default defineConfig({
three: path.resolve(__dirname, 'node_modules/three'),
...codemirrorSingletons
},
- dedupe: Object.keys(codemirrorSingletons)
+ // Deep `antd/es/*` imports are optimized separately from the app entry.
+ // Without dedupe they get their own React chunk, and hooks throw
+ // "Cannot read properties of null (reading 'useContext')".
+ dedupe: ['react', 'react-dom', ...Object.keys(codemirrorSingletons)]
},
optimizeDeps: {
// Do not prebundle CodeMirror — pnpm + esbuild duplicates @codemirror/state
// and breaks FacetProvider instanceof checks.
- include: ['@ant-design/icons'],
+ // Include antd's internal entry points up front so they share the app's
+ // React chunk instead of being discovered later and bundled apart.
+ include: [
+ 'react',
+ 'react-dom',
+ 'react-dom/client',
+ 'react/jsx-dev-runtime',
+ 'antd',
+ 'antd/es/config-provider/hooks/useCSSVarCls',
+ 'antd/es/config-provider/hooks/useSize',
+ 'antd/es/input/style',
+ 'antd/es/space/Compact',
+ 'antd/es/layout/layout',
+ '@ant-design/icons'
+ ],
exclude: [
'@uiw/react-codemirror',
'codemirror',