@@ -552,6 +620,15 @@ function SideRail({
))}
+
+ {online ? t.pwa.online : t.pwa.offline}
+ {pwaState}
+
+ {canInstall ? (
+
+ ) : null}
{activeLabel}
+
+ {freshnessLabel(freshness, t)}
+
+
+
+ {!secureContext
+ ? t.pwa.secureRequired
+ : installed
+ ? t.pwa.installed
+ : offlineReady
+ ? t.pwa.ready
+ : t.pwa.app}
+
+ {canInstall ? (
+
+ ) : null}
+
>
) : null}
@@ -675,7 +783,27 @@ function SectionNav({
);
}
-function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
+function ControlRail({
+ canInstall,
+ data,
+ installed,
+ locale,
+ offlineReady,
+ onInstall,
+ online,
+ secureContext,
+ t,
+}: {
+ canInstall: boolean;
+ data: DashboardState;
+ installed: boolean;
+ locale: Locale;
+ offlineReady: boolean;
+ onInstall: () => void;
+ online: boolean;
+ secureContext: boolean;
+ t: Messages;
+}) {
const queue = data.snapshots.reduce(
(acc, snapshot) => {
for (const entry of snapshot.entries) {
@@ -686,9 +814,44 @@ function ControlRail({ data, t }: { data: DashboardState; t: Messages }) {
},
{ pendingTasks: 0, pendingMessageRooms: 0 },
);
+ const freshness = dashboardFreshness(online, data.overview.generatedAt);
+ const age = dashboardAgeMs(data.overview.generatedAt);
return (
+
+ {t.pwa.updated}
+ {freshnessLabel(freshness, t)}
+
+ {formatDate(data.overview.generatedAt, locale)}
+ {age === null ? '' : ` · ${formatDuration(age, t)}`}
+
+
+
+ {t.pwa.app}
+
+ {!secureContext
+ ? t.pwa.secureRequired
+ : installed
+ ? t.pwa.installed
+ : offlineReady
+ ? t.pwa.ready
+ : t.pwa.app}
+
+ {canInstall ? (
+
+ ) : (
+
+ {!secureContext
+ ? t.pwa.secureRequired
+ : offlineReady
+ ? t.pwa.cached
+ : t.pwa.online}
+
+ )}
+
{t.metrics.rooms}
@@ -1771,6 +1934,13 @@ function App() {
const [drawerOpen, setDrawerOpen] = useState(false);
const [activeView, setActiveView] = useState(readViewFromHash);
const [locale, setLocale] = useState(readInitialLocale);
+ const [online, setOnline] = useState(() =>
+ typeof navigator === 'undefined' ? true : navigator.onLine,
+ );
+ const [offlineReady, setOfflineReady] = useState(false);
+ const [installPrompt, setInstallPrompt] =
+ useState(null);
+ const [installed, setInstalled] = useState(isStandaloneDisplay);
const [taskActionKey, setTaskActionKey] = useState(
null,
);
@@ -1781,6 +1951,8 @@ function App() {
useState(null);
const [roomMessageKey, setRoomMessageKey] = useState(null);
const t = messages[locale];
+ const secureContext =
+ typeof window === 'undefined' ? true : window.isSecureContext;
function setDashboardLocale(nextLocale: Locale) {
setLocale(nextLocale);
@@ -1922,10 +2094,90 @@ function App() {
}
}
+ async function handleInstallApp() {
+ if (!installPrompt) return;
+ await installPrompt.prompt();
+ await installPrompt.userChoice;
+ setInstallPrompt(null);
+ setInstalled(isStandaloneDisplay());
+ }
+
useEffect(() => {
document.documentElement.lang = localeTags[locale];
}, [locale]);
+ useEffect(() => {
+ function handleOnline() {
+ setOnline(true);
+ }
+
+ function handleOffline() {
+ setOnline(false);
+ }
+
+ window.addEventListener('online', handleOnline);
+ window.addEventListener('offline', handleOffline);
+ return () => {
+ window.removeEventListener('online', handleOnline);
+ window.removeEventListener('offline', handleOffline);
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!import.meta.env.PROD || !canUsePwaCore()) {
+ setOfflineReady(false);
+ return;
+ }
+
+ let cancelled = false;
+ void navigator.serviceWorker
+ .register('/sw.js')
+ .then((registration) => {
+ if (!cancelled) {
+ setOfflineReady(
+ Boolean(
+ registration.active ||
+ registration.waiting ||
+ registration.installing,
+ ),
+ );
+ }
+ return navigator.serviceWorker.ready;
+ })
+ .then(() => {
+ if (!cancelled) setOfflineReady(true);
+ })
+ .catch(() => {
+ if (!cancelled) setOfflineReady(false);
+ });
+
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ useEffect(() => {
+ function handleBeforeInstallPrompt(event: Event) {
+ event.preventDefault();
+ setInstallPrompt(event as BeforeInstallPromptEvent);
+ }
+
+ function handleInstalled() {
+ setInstalled(true);
+ setInstallPrompt(null);
+ }
+
+ window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
+ window.addEventListener('appinstalled', handleInstalled);
+ return () => {
+ window.removeEventListener(
+ 'beforeinstallprompt',
+ handleBeforeInstallPrompt,
+ );
+ window.removeEventListener('appinstalled', handleInstalled);
+ };
+ }, []);
+
useEffect(() => {
function handleHashChange() {
setActiveView(readViewFromHash());
@@ -1960,29 +2212,43 @@ function App() {
}
const roomOptions = data ? buildRoomOptions(data.snapshots) : [];
+ const freshness = dashboardFreshness(online, data?.overview.generatedAt);
+ const canInstall = Boolean(secureContext && installPrompt && !installed);
return (
void handleInstallApp()}
onNavigate={navigateToView}
onLocaleChange={setDashboardLocale}
onRefresh={() => void refresh(true)}
refreshing={refreshing}
+ secureContext={secureContext}
t={t}
/>
setDrawerOpen(false)}
+ onInstall={() => void handleInstallApp()}
onLocaleChange={setDashboardLocale}
onNavigate={navigateToView}
onOpenDrawer={() => setDrawerOpen(true)}
+ offlineReady={offlineReady}
onRefresh={() => void refresh(true)}
refreshing={refreshing}
+ secureContext={secureContext}
t={t}
/>
@@ -2008,7 +2274,17 @@ function App() {
-
+ void handleInstallApp()}
+ secureContext={secureContext}
+ t={t}
+ />
>
) : null}
diff --git a/apps/dashboard/src/i18n.ts b/apps/dashboard/src/i18n.ts
index 618ad93..836e66b 100644
--- a/apps/dashboard/src/i18n.ts
+++ b/apps/dashboard/src/i18n.ts
@@ -38,6 +38,19 @@ export interface Messages {
activeRooms: string;
pendingRooms: string;
};
+ pwa: {
+ app: string;
+ install: string;
+ installed: string;
+ ready: string;
+ cached: string;
+ online: string;
+ offline: string;
+ fresh: string;
+ stale: string;
+ secureRequired: string;
+ updated: string;
+ };
metrics: {
agents: string;
rooms: string;
@@ -299,6 +312,19 @@ export const messages = {
activeRooms: '처리 + 대기 룸',
pendingRooms: '메시지 대기 룸',
},
+ pwa: {
+ app: 'PWA',
+ install: '설치',
+ installed: '설치됨',
+ ready: '오프라인 준비',
+ cached: '캐시됨',
+ online: '온라인',
+ offline: '오프라인',
+ fresh: '최신',
+ stale: '지연',
+ secureRequired: 'HTTPS 필요',
+ updated: '갱신',
+ },
metrics: {
agents: '에이전트',
rooms: '룸',
@@ -544,6 +570,19 @@ export const messages = {
activeRooms: 'processing + waiting rooms',
pendingRooms: 'rooms with pending messages',
},
+ pwa: {
+ app: 'PWA',
+ install: 'Install',
+ installed: 'Installed',
+ ready: 'Offline ready',
+ cached: 'Cached',
+ online: 'Online',
+ offline: 'Offline',
+ fresh: 'Fresh',
+ stale: 'Stale',
+ secureRequired: 'HTTPS required',
+ updated: 'Updated',
+ },
metrics: {
agents: 'agents',
rooms: 'rooms',
@@ -789,6 +828,19 @@ export const messages = {
activeRooms: '处理中 + 等待房间',
pendingRooms: '有待处理消息的房间',
},
+ pwa: {
+ app: 'PWA',
+ install: '安装',
+ installed: '已安装',
+ ready: '离线可用',
+ cached: '已缓存',
+ online: '在线',
+ offline: '离线',
+ fresh: '最新',
+ stale: '延迟',
+ secureRequired: '需要 HTTPS',
+ updated: '更新',
+ },
metrics: {
agents: '代理',
rooms: '房间',
@@ -1034,6 +1086,19 @@ export const messages = {
activeRooms: '処理中 + 待機ルーム',
pendingRooms: 'メッセージ待ちルーム',
},
+ pwa: {
+ app: 'PWA',
+ install: 'インストール',
+ installed: 'インストール済み',
+ ready: 'オフライン可',
+ cached: 'キャッシュ済み',
+ online: 'オンライン',
+ offline: 'オフライン',
+ fresh: '最新',
+ stale: '遅延',
+ secureRequired: 'HTTPS 必須',
+ updated: '更新',
+ },
metrics: {
agents: 'エージェント',
rooms: 'ルーム',
diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css
index af84265..c781d43 100644
--- a/apps/dashboard/src/styles.css
+++ b/apps/dashboard/src/styles.css
@@ -206,6 +206,29 @@ button:disabled {
white-space: nowrap;
}
+.topbar-status {
+ display: inline-flex;
+ min-height: 34px;
+ align-items: center;
+ padding: 0 11px;
+ border-radius: 999px;
+ color: var(--muted);
+ background: rgba(43, 55, 38, 0.075);
+ font-size: 12px;
+ font-weight: 900;
+ white-space: nowrap;
+}
+
+.topbar-status-offline {
+ color: #7c2518;
+ background: rgba(183, 71, 52, 0.14);
+}
+
+.topbar-status-stale {
+ color: #6e4a05;
+ background: rgba(181, 138, 34, 0.16);
+}
+
.side-rail {
position: sticky;
top: 18px;
@@ -271,6 +294,46 @@ button:disabled {
background: rgba(191, 95, 44, 0.12);
}
+.side-install {
+ width: 100%;
+ min-height: 44px;
+ background: linear-gradient(135deg, #2b3726, #1c211c);
+ box-shadow: 0 12px 26px rgba(28, 33, 28, 0.18);
+ font-weight: 950;
+}
+
+.pwa-card {
+ display: grid;
+ gap: 3px;
+ min-width: 0;
+ padding: 11px 12px;
+ border: 1px solid rgba(43, 55, 38, 0.1);
+ border-radius: 17px;
+ background: rgba(43, 55, 38, 0.045);
+}
+
+.pwa-card span,
+.drawer-pwa-row span {
+ color: var(--muted);
+ font-size: 11px;
+ font-weight: 900;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.pwa-card strong {
+ min-width: 0;
+ overflow: hidden;
+ font-size: 13px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.pwa-card.is-offline {
+ border-color: rgba(183, 71, 52, 0.24);
+ background: rgba(183, 71, 52, 0.08);
+}
+
.language-select {
display: grid;
gap: 8px;
@@ -347,6 +410,19 @@ button:disabled {
box-shadow: none;
}
+.drawer-pwa-row {
+ display: grid;
+ gap: 8px;
+ padding-top: 2px;
+}
+
+.drawer-pwa-row button {
+ min-height: 44px;
+ padding: 0 14px;
+ font-size: 13px;
+ font-weight: 950;
+}
+
.nav-drawer nav {
display: grid;
align-content: start;
@@ -409,7 +485,7 @@ button:disabled {
.ops-strip {
display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
+ grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
margin: 0;
overflow: hidden;
@@ -457,6 +533,27 @@ button:disabled {
line-height: 1.35;
}
+.ops-fresh {
+ border-color: rgba(63, 127, 81, 0.16);
+}
+
+.ops-stale {
+ border-color: rgba(181, 138, 34, 0.22);
+}
+
+.ops-offline {
+ border-color: rgba(183, 71, 52, 0.28);
+}
+
+.ops-tile-pwa button {
+ min-height: 30px;
+ width: fit-content;
+ padding: 0 10px;
+ box-shadow: none;
+ font-size: 12px;
+ font-weight: 950;
+}
+
.skeleton-card {
display: grid;
gap: 8px;
@@ -1787,7 +1884,7 @@ progress::-moz-progress-bar {
top: max(8px, env(safe-area-inset-top));
right: 10px;
left: 10px;
- grid-template-columns: 52px minmax(0, 1fr) 54px;
+ grid-template-columns: 52px minmax(0, 1fr) auto 54px;
gap: 6px;
margin: 0;
padding: 8px;
@@ -1818,6 +1915,12 @@ progress::-moz-progress-bar {
min-width: 0;
}
+ .topbar-status {
+ min-height: 30px;
+ padding: 0 8px;
+ font-size: 10px;
+ }
+
.section-nav .refresh-button::before {
color: #fffaf0;
content: '↻';
@@ -2004,6 +2107,12 @@ progress::-moz-progress-bar {
.section-nav {
right: 8px;
left: 8px;
+ grid-template-columns: 48px minmax(0, 1fr) auto 50px;
+ }
+
+ .topbar-status {
+ padding: 0 7px;
+ letter-spacing: -0.01em;
}
.usage-summary {
@@ -2057,6 +2166,12 @@ progress::-moz-progress-bar {
}
}
+@media (display-mode: standalone) {
+ .shell {
+ padding-top: max(18px, env(safe-area-inset-top));
+ }
+}
+
@media (prefers-reduced-motion: reduce) {
*,
*::before,