# v2/web-ui-offline-overlay.md — Web UI Server Offline Overlay > 所屬:TDD v2 §2.6 > 版本:v2.1(2026-04-14 吸收 Minor 4 WebSocket shutdown-imminent 廣播) > 決策依據:R5-2(關閉 Wails 視窗 = server 停,瀏覽器顯示 offline overlay)、三方共識 #14(boot-id + retry)、PM Minor 4(WebSocket 廣播即時觸發 overlay,消除 race condition) > 對應 milestone:M8-7 > 相關文件:`v2/server-lifecycle.md` §8.3(Wails 端 WebSocket 廣播時機)、§9(server 端 boot-id API) --- ## 1. 目的 當瀏覽器 tab 偵測到 server 連不上(Wails app 被關了、使用者按 Stop、server crash)時,顯示一個全域的覆蓋層告訴使用者「Server 已離線」,並提供重試機制。同時實作 boot-id polling 讓 server 重啟後瀏覽器自動 reload。 --- ## 2. UI 設計 ``` ┌─────────────────────────────────────────────────────┐ │ │ │ ⚠️ │ │ │ │ Server 已離線 │ │ │ │ visionA Local 的本機伺服器沒有回應。 │ │ 可能原因: │ │ • 你關閉了 visionA Local 控制台 │ │ • Server 被手動停止 │ │ • Server 發生錯誤 │ │ │ │ 請重新開啟 visionA Local 應用程式後按「重試」。 │ │ │ │ [重試] [關閉這個頁面] │ │ │ └─────────────────────────────────────────────────────┘ ``` - 全螢幕半透明 backdrop(`rgba(0,0,0,0.65)`) - 中央卡片(`max-w-md`) - 中英文隨現有 i18n locale - 不擋 devtools、不擋 keyboard(使用者可用 devtools 檢查 network 看是不是真連不上) --- ## 3. 偵測機制 ### 3.1 偵測參數定版(v2.1 Architect 二次審閱補齊) **Polling 間隔與失敗門檻(定版)**: | 參數 | 值 | 說明 | |------|-----|------| | Health check 間隔(正常模式) | **10 秒** | server 正常時的 boot-id polling 間隔 | | Health check 間隔(active retry 模式)| **3 秒** | overlay 顯示後自動重試的 polling 間隔 | | 連續失敗門檻 | **2 次**(約 20 秒)| 避免偶發網路抖動誤觸發 | | fetch 單次 timeout | 3 秒 | `AbortSignal.timeout(3000)` | **Active retry 模式定義**:使用者第一次看到 overlay 後(或主動點 Retry 按鈕),前端**自動**每 3 秒重打一次 `/api/system/boot-id`,不需要使用者持續按按鈕。只要任一次成功 → 立即 `resetFailures()` → overlay 消失(若 bootId 變了則 `window.location.reload()`)。 **觸發管道**:Overlay 顯示由三個獨立管道觸發,任一觸發即顯示(優先序由高到低): 1. **WebSocket close event(v2.1 新增)**:瀏覽器端 WebSocket 收到 `onclose` → **立即**顯示 overlay(不等 polling);理由:close 事件代表 server 端已斷,polling 也一定會失敗,不需要等 20 秒 2. **WebSocket `server:shutdown-imminent` 事件(Minor 4)**:收到廣播訊息 → **立即**顯示 overlay,並依 `reason` 欄位決定文案: - `reason="app-closing"` / `reason="manual-stop"` / `reason="quit"` → 立即顯示 - `reason="restart"` → 延遲 10 秒顯示(給 boot-id 變化 → `window.location.reload()` 的時間) 3. **被動偵測(polling fallback)**:連續 2 次 boot-id polling 失敗 → 顯示 overlay(當 WebSocket 已斷線或從未建立時生效) WebSocket 管道的優勢: - **零延遲**:Wails 開始 SIGTERM 的瞬間就發廣播(在實際 shutdown 完成前),瀏覽器 tab 幾乎同時看到 overlay - **消除 race condition**:原 v2.0 設計下,使用者關 Wails 視窗到瀏覽器 tab 看到 overlay 有 0.5-20 s 的 window(取決於 polling 時機),這段期間使用者可能對著一個「已失效但看起來正常」的頁面操作 - **回退**:若 WebSocket 已斷線或從未建立(極罕見,例如 Next.js 載入失敗),polling fallback 仍有效 ### 3.2 Polling 策略(v2.1 定版) ``` 瀏覽器 tab 載入 ↓ initial fetch /api/system/boot-id ↓ 成功 → 記錄 initialBootId + startedAt,進 normal polling(10 s interval) ↓ 下一次 poll: ├─ 成功 → 比對 bootId │ ├─ 相同 → 無事 │ └─ 不同 → window.location.reload()(server 重啟,強制 reload) └─ 失敗 → consecutiveFailures++ ├─ < 2 → 不顯示 overlay(避免偶發網路 glitch) └─ ≥ 2(連續 ~20 s)→ 顯示 + 切 polling interval 為 3 s(active retry 模式) ↓ Active retry 模式下: 每 3 s 自動重打 /api/system/boot-id ├─ 成功(bootId 相同)→ resetFailures() + 切回 10 s interval → overlay 消失 └─ 成功(bootId 不同)→ window.location.reload() ``` ### 3.2a WebSocket `server:shutdown-imminent` 偵測 ``` 瀏覽器 tab 已連上 WebSocket(例如 /ws/server-logs 或新增的 /ws/system) ↓ server 收到 Wails 的 ctrl.Stop() 呼叫 ↓ server 對 WebSocket hub 廣播: { "type": "server:shutdown-imminent", "reason": "app-closing" | "manual-stop" | "restart", "ts": 1744656180123 } ↓ 瀏覽器收到 message → store.forceShowOverlay() ↓ 立即顯示(不等 polling 失敗) ↓ 後續 SIGTERM、server 真的停止、polling 失敗 → 疊加在 overlay 上不重複觸發 ``` **reason 欄位用途(v2.1 定版)**: - `app-closing`:Wails OnShutdown 觸發,應用即將退出 → 立即顯示 overlay,文案「visionA Local 已關閉」 - `manual-stop`:使用者按控制台的 Stop → 立即顯示 overlay,文案「Server 已停止,請在控制台按 Start」 - `quit`:使用者從 Wails 選單選 Quit(macOS Cmd+Q 等)→ 立即顯示 overlay,文案同 `app-closing` - `restart`:使用者按控制台的 Restart → **延遲 10 秒**再顯示 overlay(給 boot-id 變化 → `window.location.reload()` 的時間);若 10 s 內 server 回來 + boot-id 變 → reload → overlay 不會真的顯示 ### 3.3 失敗計數策略(polling 管道,v2.1 定版) - **Normal 模式**:10 s poll interval,連續 2 次失敗(= 覆蓋 ~20 s 容忍期)才觸發 overlay - **Active retry 模式**:overlay 顯示後切 3 s poll interval,自動重試至 server 回來 - Wails 關閉視窗的 `StopServer` 過程約 0.5-1 s,瀏覽器 polling 通常靠 WebSocket close 事件或 `server:shutdown-imminent` 廣播立即顯示 overlay,而非等 polling 失敗 - 使用者正常 Restart(透過控制台)約 2-3 s,boot-id 會變但也不會連續 2 次失敗 → 下一次成功 poll 偵測 boot-id 變化 → force reload - 網路偶發抖動(< 10 s 單次 glitch)→ 最多 1 次 failure,下一次 poll 成功即清零,不觸發 overlay ### 3.3 Page Visibility API(R-v2-6) 瀏覽器背景 tab 的 setInterval 會被降頻到 1 次/分鐘,會讓「server 重啟後使用者切回 tab」體驗不好。 解決: ```typescript const BOOT_ID_POLL_MS = 5000; let intervalHandle: number | null = null; function startPolling() { if (intervalHandle !== null) return; intervalHandle = window.setInterval(pollOnce, BOOT_ID_POLL_MS); } function stopPolling() { if (intervalHandle !== null) { clearInterval(intervalHandle); intervalHandle = null; } } document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { pollOnce(); // 立刻 probe 一次 startPolling(); } else { stopPolling(); // 背景 tab 不 poll,回來再說 } }); // 初始:tab 可見時才啟動 if (document.visibilityState === 'visible') { startPolling(); } ``` --- ## 4. 實作 ### 4.1 檔案清單 | 檔案 | 狀態 | 說明 | |------|------|------| | `frontend/src/stores/system-store.ts` | **新增** | Zustand store:`serverOnline / bootId / forcedOffline / lastError` | | `frontend/src/hooks/use-boot-id-watcher.ts` | **新增** | React hook:掛 useEffect 啟動 polling + visibility handling | | `frontend/src/hooks/use-shutdown-watcher.ts` | **新增**(v2.1)| React hook:訂閱 WebSocket `server:shutdown-imminent` 事件(Minor 4)| | `frontend/src/components/server-offline-overlay.tsx` | **新增** | 全域覆蓋層 UI(shadcn Dialog / Card) | | `frontend/src/components/boot-id-watcher-mount.tsx` | **新增** | Client wrapper 掛 `useBootIdWatcher()` + `useShutdownWatcher()` | | `frontend/src/app/layout.tsx` | **修改** | 在 root layout 掛 `` 和 `` | | `frontend/src/lib/i18n/types.ts` | **修改** | 新增 `serverOffline` 區塊的 i18n key 定義(含 3 種 reason 文案)| | `frontend/src/lib/i18n/zh-TW.ts` | **修改** | 新增繁體中文字串 | | `frontend/src/lib/i18n/en.ts` | **修改** | 新增英文字串 | ### 4.2 `frontend/src/stores/system-store.ts` ```typescript import { create } from 'zustand'; interface SystemState { serverOnline: boolean; bootId: string | null; consecutiveFailures: number; lastProbeAt: number | null; // Minor 4:由 WebSocket shutdown-imminent 事件強制設定,讓 overlay 立即顯示 forcedOffline: boolean; forcedOfflineReason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' | null; setOnline: (bootId: string) => void; recordFailure: () => void; shouldShowOverlay: () => boolean; resetFailures: () => void; forceOffline: (reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit') => void; } const FAILURE_THRESHOLD = 2; // v2.1 定版:2 次失敗(約 20 s)即觸發 export const useSystemStore = create((set, get) => ({ serverOnline: true, bootId: null, consecutiveFailures: 0, lastProbeAt: null, forcedOffline: false, forcedOfflineReason: null, setOnline: (bootId) => set({ serverOnline: true, bootId, consecutiveFailures: 0, lastProbeAt: Date.now(), // 注意:recover 時不自動清 forcedOffline,要走 resetFailures(使用者點「重試」) }), recordFailure: () => set((s) => ({ consecutiveFailures: s.consecutiveFailures + 1, serverOnline: s.consecutiveFailures + 1 < FAILURE_THRESHOLD, lastProbeAt: Date.now(), })), shouldShowOverlay: () => { const s = get(); return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD; }, // Active retry 模式:overlay 顯示中時,前端自動切 3 s interval 重試 isActiveRetryMode: () => { const s = get(); return s.forcedOffline || s.consecutiveFailures >= FAILURE_THRESHOLD; }, resetFailures: () => set({ consecutiveFailures: 0, serverOnline: true, forcedOffline: false, forcedOfflineReason: null, }), forceOffline: (reason) => { // Minor 4:restart 情境不立即顯示 overlay,給 10 s 等 boot-id 變化 reload if (reason === 'restart') { setTimeout(() => { const s = get(); if (s.consecutiveFailures < FAILURE_THRESHOLD && s.bootId !== null) { // 10 s 內 server 沒回來才強制顯示 set({ forcedOffline: true, forcedOfflineReason: 'restart' }); } }, 10_000); return; } set({ forcedOffline: true, forcedOfflineReason: reason }); }, })); ``` ### 4.3 `frontend/src/hooks/use-boot-id-watcher.ts` ```typescript 'use client'; import { useEffect } from 'react'; import { useSystemStore } from '@/stores/system-store'; import { getBackendUrl } from '@/lib/api'; const POLL_INTERVAL_NORMAL_MS = 10_000; // v2.1 定版:正常模式 10 s const POLL_INTERVAL_ACTIVE_RETRY_MS = 3_000; // v2.1 定版:overlay 顯示後自動每 3 s 重試 interface BootIdResponse { success: boolean; data: { bootId: string; startedAt: number; }; } async function pollOnce(): Promise { const store = useSystemStore.getState(); try { const res = await fetch(`${getBackendUrl()}/api/system/boot-id`, { cache: 'no-store', // 短 timeout:若 server 假死要快速失敗 signal: AbortSignal.timeout(3000), }); if (!res.ok) { store.recordFailure(); return; } const json = (await res.json()) as BootIdResponse; if (!json.success || !json.data?.bootId) { store.recordFailure(); return; } const newBootId = json.data.bootId; if (store.bootId !== null && store.bootId !== newBootId) { // Server 重啟:force reload window.location.reload(); return; } store.setOnline(newBootId); } catch { store.recordFailure(); } } export function useBootIdWatcher(): void { useEffect(() => { let intervalHandle: number | null = null; let currentMode: 'normal' | 'active-retry' = 'normal'; const intervalFor = (mode: 'normal' | 'active-retry') => mode === 'active-retry' ? POLL_INTERVAL_ACTIVE_RETRY_MS : POLL_INTERVAL_NORMAL_MS; const start = (mode: 'normal' | 'active-retry') => { if (intervalHandle !== null && currentMode === mode) return; if (intervalHandle !== null) clearInterval(intervalHandle); currentMode = mode; intervalHandle = window.setInterval(pollOnce, intervalFor(mode)); }; const stop = () => { if (intervalHandle !== null) { clearInterval(intervalHandle); intervalHandle = null; } }; // 監聽 store 變化,overlay 顯示 ↔ 隱藏時切換 polling 模式 const unsubscribe = useSystemStore.subscribe((state) => { const shouldActiveRetry = state.forcedOffline || state.consecutiveFailures >= 2; const targetMode = shouldActiveRetry ? 'active-retry' : 'normal'; if (intervalHandle !== null && targetMode !== currentMode) { start(targetMode); // 自動切換 interval } }); const onVisibilityChange = () => { if (document.visibilityState === 'visible') { void pollOnce(); // 立即 probe const s = useSystemStore.getState(); const mode = s.forcedOffline || s.consecutiveFailures >= 2 ? 'active-retry' : 'normal'; start(mode); } else { stop(); } }; // 初次載入時執行一次 probe + 啟動 normal 輪詢 void pollOnce(); if (document.visibilityState === 'visible') { start('normal'); } document.addEventListener('visibilitychange', onVisibilityChange); return () => { document.removeEventListener('visibilitychange', onVisibilityChange); unsubscribe(); stop(); }; }, []); } ``` ### 4.3a `frontend/src/hooks/use-shutdown-watcher.ts`(Minor 4 新增) 訂閱 server 的 WebSocket 廣播,接收 `server:shutdown-imminent` 事件後立即設 `forcedOffline`。 ```typescript 'use client'; import { useEffect } from 'react'; import { useSystemStore } from '@/stores/system-store'; import { getBackendUrl } from '@/lib/api'; interface ShutdownImminentMessage { type: 'server:shutdown-imminent'; reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit'; ts: number; } export function useShutdownWatcher(): void { useEffect(() => { const wsUrl = getBackendUrl().replace(/^http/, 'ws') + '/ws/system'; let ws: WebSocket | null = null; let reconnectTimer: number | null = null; const connect = () => { try { ws = new WebSocket(wsUrl); } catch { scheduleReconnect(); return; } ws.onmessage = (ev) => { try { const msg = JSON.parse(String(ev.data)); if (msg?.type === 'server:shutdown-imminent') { const reason = (msg as ShutdownImminentMessage).reason; useSystemStore.getState().forceOffline(reason); } } catch { // 非 JSON 或格式錯誤,忽略 } }; ws.onclose = (ev) => { ws = null; // v2.1:WebSocket close 事件也視為 server 已斷,立即顯示 overlay // (比等 polling 失敗 20 s 更即時;若 server 正常重啟,成功 poll 後會自動清 overlay) // 只在「曾經連上」的前提下才觸發,避免初次連線就失敗導致誤觸發 if (ev && ev.wasClean === false) { const reason: 'app-closing' | 'manual-stop' | 'restart' | 'quit' = 'manual-stop'; useSystemStore.getState().forceOffline(reason); } scheduleReconnect(); }; ws.onerror = () => { // 忽略 — 後續 onclose 會處理 }; }; const scheduleReconnect = () => { if (reconnectTimer !== null) return; reconnectTimer = window.setTimeout(() => { reconnectTimer = null; if (document.visibilityState === 'visible') { connect(); } }, 3000); }; connect(); return () => { if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; } if (ws) { ws.onclose = null; ws.onerror = null; ws.close(); ws = null; } }; }, []); } ``` **server 端配合(server-lifecycle.md §8)**:`ctrl.Stop()` 開始時透過 server 的 WebSocket hub(建議新增 `/ws/system` endpoint 或沿用現有 `/ws/server-logs`)廣播該事件。 **注意**:此 hook 的 WebSocket 是從瀏覽器連到 server,與 Wails IPC 無關。Wails 端只需要在 `ctrl.Stop()` 時透過 HTTP / gRPC / 直接從 server 內呼叫 `hub.Broadcast(...)` 即可(具體做法視 server 內部 API 而定)。 ### 4.4 `frontend/src/components/server-offline-overlay.tsx` ```tsx 'use client'; import { useSystemStore } from '@/stores/system-store'; import { useTranslation } from '@/lib/i18n'; import { Button } from '@/components/ui/button'; import { AlertTriangle } from 'lucide-react'; async function retryOnce() { // 重試 = 立即打一次 boot-id,若成功會自動清 failures try { const res = await fetch('/api/system/boot-id', { cache: 'no-store' }); if (res.ok) { const json = await res.json(); if (json.success && json.data?.bootId) { useSystemStore.getState().setOnline(json.data.bootId); return; } } useSystemStore.getState().recordFailure(); } catch { useSystemStore.getState().recordFailure(); } } export function ServerOfflineOverlay() { const show = useSystemStore((s) => s.forcedOffline || s.consecutiveFailures >= 2); const reason = useSystemStore((s) => s.forcedOfflineReason); const { t } = useTranslation(); if (!show) return null; return (

{t('serverOffline.title')}

{t('serverOffline.body')}

  • {t('serverOffline.reasons.appClosed')}
  • {t('serverOffline.reasons.manuallyStopped')}
  • {t('serverOffline.reasons.crashed')}

{t('serverOffline.instruction')}

); } ``` ### 4.5 `frontend/src/app/layout.tsx` 修改 ```diff +import { ServerOfflineOverlay } from '@/components/server-offline-overlay'; +import { BootIdWatcherMount } from '@/components/boot-id-watcher-mount'; export default function RootLayout({ children }: { children: React.ReactNode }) { return ( {/* 既有的 providers / sidebar / theme 等 */} {children} + + ); } ``` `BootIdWatcherMount` 是一個空的 `'use client'` 元件,作用只是掛 `useBootIdWatcher()`(hook 不能直接在 layout 的 server component 呼叫): ```tsx // frontend/src/components/boot-id-watcher-mount.tsx 'use client'; import { useBootIdWatcher } from '@/hooks/use-boot-id-watcher'; import { useShutdownWatcher } from '@/hooks/use-shutdown-watcher'; export function BootIdWatcherMount() { useBootIdWatcher(); useShutdownWatcher(); // Minor 4:WebSocket shutdown-imminent 訂閱 return null; } ``` ### 4.6 i18n 字串 **`frontend/src/lib/i18n/types.ts`** 新增: ```typescript serverOffline: { title: string; body: string; reasons: { appClosed: string; manuallyStopped: string; crashed: string; }; instruction: string; retry: string; closeTab: string; }; ``` **`frontend/src/lib/i18n/zh-TW.ts`**: ```typescript serverOffline: { title: 'Server 已離線', body: 'visionA Local 的本機伺服器沒有回應。可能的原因:', reasons: { appClosed: '你關閉了 visionA Local 應用程式', manuallyStopped: 'Server 被手動停止', crashed: 'Server 發生錯誤', }, instruction: '請重新開啟 visionA Local 應用程式後按「重試」。', retry: '重試', closeTab: '關閉這個頁面', }, ``` **`frontend/src/lib/i18n/en.ts`**: ```typescript serverOffline: { title: 'Server Offline', body: 'The visionA Local server is not responding. Possible reasons:', reasons: { appClosed: 'You closed the visionA Local app', manuallyStopped: 'Server was manually stopped', crashed: 'Server encountered an error', }, instruction: 'Please relaunch visionA Local and click "Retry".', retry: 'Retry', closeTab: 'Close this tab', }, ``` --- ## 5. SSR 相容性 Next.js 的 `static export` 會在 build 階段把頁面 render 成 HTML。以下程式碼都必須處理 SSR(`typeof window === 'undefined'` 情境): - `use-boot-id-watcher.ts` — 標 `'use client'`,只在瀏覽器 run,OK - `server-offline-overlay.tsx` — 標 `'use client'`,OK - `system-store.ts` — Zustand 不需 `'use client'`,但 store 只被 client component 使用,OK - `boot-id-watcher-mount.tsx` — `'use client'`,OK **驗收**:`pnpm build` 在 frontend/ 目錄下要成功,沒有 `ReferenceError: window is not defined` 或其他 SSR 錯誤。 --- ## 6. 與 ServerController 事件的關係(v2.1 更新) Wails 控制台的 log panel 會收到 `server:state-change` 事件並更新狀態卡片;**瀏覽器 tab 無法收到 Wails event**(不同 context)。瀏覽器的真相來源有三個,優先序由高到低: 1. **WebSocket `server:shutdown-imminent`(最即時,v2.1 Minor 4 新增)**:Wails 的 `ctrl.Stop()` 在開始 SIGTERM 前透過 server 的 WebSocket hub 廣播,瀏覽器 tab 幾乎同時收到 → 立即顯示 overlay 2. **`GET /api/system/boot-id` polling**: - 成功 + bootId 變 → `window.location.reload()`(server 重啟後同 port) - 連續 2 次失敗 → 顯示 overlay(fallback,當 WebSocket 已斷或未建立) 3. **業務 API 呼叫的 network error**:次要,不當作 overlay 觸發器(避免單一 API 問題誤觸發) Wails → server → WebSocket → 瀏覽器 的路徑:Wails 只與 server 進程通訊(透過 stdin/stdout 或 HTTP),server 再轉發到 WebSocket hub 廣播給所有連線 tab。這是單向 push,比 polling 更即時。 --- ## 7. 驗收條件 | 檢查 | 操作 | 預期 | |------|------|------| | 正常啟動沒 overlay | 開 app → 點 Open in Browser → tab 載入 | overlay 不顯示 | | 關 Wails → 立即顯示 overlay(WebSocket 管道)| 開 app → Open in Browser → 關 Wails 視窗 | < 1 s 內 tab 顯示 overlay(WebSocket `shutdown-imminent` 或 `onclose` 觸發)| | 關 Wails → polling fallback | 同上但 WebSocket 未建立(mock) | 20 s 內 tab 顯示 overlay(連續 2 次 10 s poll 失敗)| | 關 Wails → 瀏覽器 tab 顯示正確訊息 | 同上 | 看到 title「Server 已離線」 | | Active retry 模式自動重試 | Overlay 顯示後 | 前端自動每 3 s 打 boot-id(非使用者手動按)| | Overlay 重試按鈕 | server 關著時點「重試」 | failures +1 或維持(視結果),overlay 仍在 | | 重試 → 成功 | 關 app → 等 overlay → 重開 app | Active retry 3 s 內自動 dismiss overlay;若 bootId 變則 reload | | 控制台按 Restart → 自動 reload | server running → 在 Wails 按 Restart → 3 s 後 server 回來 | tab 自動 `window.location.reload()`,載入後正常;期間不顯示 overlay(`restart` reason 延遲 10 s)| | 控制台按 Stop → 立即 overlay | Stop | WebSocket 廣播 `manual-stop` → 立即顯示 overlay | | 背景 tab 不浪費資源 | 切到別的 tab → `chrome://performance-metrics` | tab 不在時 setInterval 停(visibility hidden) | | 切回背景 tab → 立即 probe | 切回 tab | 立即打 boot-id,不等 10 s interval | | 網路 glitch < 10 s 不顯示 | 人工用 DevTools Network → Offline 8 s → Online | overlay 不顯示(最多 1 次失敗,未達 2 次門檻) | --- ## 8. 待確認 1. **`window.close()` 在非 `window.open()` 開的 tab 不能用** — 使用者從 Wails Open in Browser 進來的 tab 確實是 browser 自己開的,不是 `window.open`,Chrome 會拒絕 `window.close()`。備案:把「關閉這個頁面」按鈕改為顯示「請手動關閉此分頁」提示。M8-7 執行者實測後決定 UI 文案。 2. **boot-id cache header** — 目前用 `cache: 'no-store'`,但某些 Chrome 版本會忽略。若發現 polling 拿到快取值(bootId 不變),改加 `?_=Date.now()` query 防快取。 3. **Overlay z-index 衝突** — 9999 高於 shadcn Dialog 的 50,應夠。若未來有其他「更上層」元件要放,確認 z-index layering。