Skip to content

Commit 3ec56d8

Browse files
committed
fix(ui): global toast provider so batch-action toasts survive page reload
Toast notifications would flash for ~3s — or not appear at all on slow batch operations — because each page rendered <ToastNotice> inside <StateShell>'s children, and any reload() triggered loading=true which unmounts the entire children tree (toast included). Changes: - New ToastProvider (context + top-level mount in App.tsx) so the toast surface lives outside every StateShell and survives loading transitions - Default timeout bumped 3000ms → 4500ms (batch results need more reading time); per-call override via showToast(msg, type, ms) - useToast() now reads the global context — API unchanged, all 77 existing callers work without modification - Drop redundant <ToastNotice> mounts from 8 pages - handleBatchTest in Accounts.tsx now uses reloadSilently so the post-batch page doesn't blank to a spinner (which used to hide the toast too)
1 parent 55b9e45 commit 3ec56d8

12 files changed

Lines changed: 89 additions & 71 deletions

File tree

frontend/src/App.tsx

Lines changed: 28 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import AuthGate from './components/AuthGate'
44
import Layout from './components/Layout'
55
import RouteErrorBoundary from './components/RouteErrorBoundary'
66
import StateShell from './components/StateShell'
7+
import { ToastProvider } from './components/ToastProvider'
78
import { BrandingProvider } from './branding'
89
import Dashboard from './pages/Dashboard'
910

@@ -23,31 +24,33 @@ export default function App() {
2324
return (
2425
<BrandingProvider>
2526
<AuthGate>
26-
<Layout>
27-
<RouteErrorBoundary>
28-
<Suspense fallback={<StateShell variant="page" loading>{null}</StateShell>}>
29-
<Routes>
30-
<Route path="/" element={<Dashboard />} />
31-
<Route path="/accounts" element={<Accounts />} />
32-
<Route path="/api-keys" element={<APIKeys />} />
33-
<Route path="/proxies" element={<Proxies />} />
34-
<Route path="/images" element={<Navigate to="/images/studio" replace />} />
35-
<Route path="/images/:view" element={<ImageStudio />} />
36-
<Route path="/prompt-filter" element={<Navigate to="/prompt-filter/overview" replace />} />
37-
<Route path="/prompt-filter/:view" element={<PromptFilter />} />
38-
<Route path="/ops" element={<Navigate to="/ops/overview" replace />} />
39-
<Route path="/ops/overview" element={<Operations />} />
40-
<Route path="/ops/errors" element={<OperationsErrors />} />
41-
<Route path="/ops/scheduler" element={<SchedulerBoard />} />
42-
<Route path="/usage" element={<Usage />} />
43-
<Route path="/settings" element={<Settings />} />
44-
<Route path="/docs" element={<Docs />} />
45-
<Route path="/guide" element={<Navigate to="/docs" replace />} />
46-
<Route path="/api-reference" element={<Navigate to="/docs#model-api" replace />} />
47-
</Routes>
48-
</Suspense>
49-
</RouteErrorBoundary>
50-
</Layout>
27+
<ToastProvider>
28+
<Layout>
29+
<RouteErrorBoundary>
30+
<Suspense fallback={<StateShell variant="page" loading>{null}</StateShell>}>
31+
<Routes>
32+
<Route path="/" element={<Dashboard />} />
33+
<Route path="/accounts" element={<Accounts />} />
34+
<Route path="/api-keys" element={<APIKeys />} />
35+
<Route path="/proxies" element={<Proxies />} />
36+
<Route path="/images" element={<Navigate to="/images/studio" replace />} />
37+
<Route path="/images/:view" element={<ImageStudio />} />
38+
<Route path="/prompt-filter" element={<Navigate to="/prompt-filter/overview" replace />} />
39+
<Route path="/prompt-filter/:view" element={<PromptFilter />} />
40+
<Route path="/ops" element={<Navigate to="/ops/overview" replace />} />
41+
<Route path="/ops/overview" element={<Operations />} />
42+
<Route path="/ops/errors" element={<OperationsErrors />} />
43+
<Route path="/ops/scheduler" element={<SchedulerBoard />} />
44+
<Route path="/usage" element={<Usage />} />
45+
<Route path="/settings" element={<Settings />} />
46+
<Route path="/docs" element={<Docs />} />
47+
<Route path="/guide" element={<Navigate to="/docs" replace />} />
48+
<Route path="/api-reference" element={<Navigate to="/docs#model-api" replace />} />
49+
</Routes>
50+
</Suspense>
51+
</RouteErrorBoundary>
52+
</Layout>
53+
</ToastProvider>
5154
</AuthGate>
5255
</BrandingProvider>
5356
)
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { createContext, useCallback, useContext, useEffect, useRef, useState, type PropsWithChildren } from 'react'
2+
import type { ToastState, ToastType } from '../types'
3+
import ToastNotice from './ToastNotice'
4+
5+
const DEFAULT_TOAST_MS = 4500
6+
7+
interface ToastContextValue {
8+
toast: ToastState | null
9+
showToast: (msg: string, type?: ToastType, timeoutMs?: number) => void
10+
setToast: (toast: ToastState | null) => void
11+
}
12+
13+
const ToastContext = createContext<ToastContextValue | null>(null)
14+
15+
export function ToastProvider({ children, defaultTimeoutMs = DEFAULT_TOAST_MS }: PropsWithChildren<{ defaultTimeoutMs?: number }>) {
16+
const [toast, setToast] = useState<ToastState | null>(null)
17+
const timeoutRef = useRef<number | null>(null)
18+
19+
const clearToastTimer = useCallback(() => {
20+
if (timeoutRef.current !== null) {
21+
window.clearTimeout(timeoutRef.current)
22+
timeoutRef.current = null
23+
}
24+
}, [])
25+
26+
const showToast = useCallback<ToastContextValue['showToast']>((msg, type = 'success', timeoutMs) => {
27+
clearToastTimer()
28+
setToast({ msg, type })
29+
const ms = timeoutMs ?? defaultTimeoutMs
30+
timeoutRef.current = window.setTimeout(() => {
31+
setToast(null)
32+
timeoutRef.current = null
33+
}, ms)
34+
}, [clearToastTimer, defaultTimeoutMs])
35+
36+
useEffect(() => clearToastTimer, [clearToastTimer])
37+
38+
return (
39+
<ToastContext.Provider value={{ toast, showToast, setToast }}>
40+
{children}
41+
<ToastNotice toast={toast} />
42+
</ToastContext.Provider>
43+
)
44+
}
45+
46+
export function useToastContext(): ToastContextValue {
47+
const ctx = useContext(ToastContext)
48+
if (!ctx) {
49+
throw new Error('useToastContext must be used inside <ToastProvider>')
50+
}
51+
return ctx
52+
}

frontend/src/hooks/useToast.ts

Lines changed: 8 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,9 @@
1-
import { useCallback, useEffect, useRef, useState } from 'react'
2-
import type { ToastState, ToastType } from '../types'
3-
4-
export function useToast(timeoutMs = 3000) {
5-
const [toast, setToast] = useState<ToastState | null>(null)
6-
const timeoutRef = useRef<number | null>(null)
7-
8-
const clearToastTimer = useCallback(() => {
9-
if (timeoutRef.current !== null) {
10-
window.clearTimeout(timeoutRef.current)
11-
timeoutRef.current = null
12-
}
13-
}, [])
14-
15-
const showToast = useCallback((msg: string, type: ToastType = 'success') => {
16-
clearToastTimer()
17-
setToast({ msg, type })
18-
timeoutRef.current = window.setTimeout(() => {
19-
setToast(null)
20-
timeoutRef.current = null
21-
}, timeoutMs)
22-
}, [clearToastTimer, timeoutMs])
23-
24-
useEffect(() => clearToastTimer, [clearToastTimer])
25-
26-
return { toast, showToast, setToast }
1+
import { useToastContext } from '../components/ToastProvider'
2+
3+
// 兼容旧调用方:`const { toast, showToast } = useToast()` 不变。
4+
// timeoutMs 参数已废弃 —— 全局 ToastProvider 用统一默认时长;
5+
// 如需单次自定义时长,直接 `showToast(msg, type, ms)`。
6+
export function useToast(_timeoutMs?: number) {
7+
void _timeoutMs
8+
return useToastContext()
279
}

frontend/src/pages/APIKeys.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import ChipInput from "../components/ChipInput";
66
import Modal from "../components/Modal";
77
import PageHeader from "../components/PageHeader";
88
import StateShell from "../components/StateShell";
9-
import ToastNotice from "../components/ToastNotice";
109
import { useConfirmDialog } from "../hooks/useConfirmDialog";
1110
import { useDataLoader } from "../hooks/useDataLoader";
1211
import { useToast } from "../hooks/useToast";
@@ -892,7 +891,6 @@ export default function APIKeys() {
892891
) : null}
893892
</Modal>
894893

895-
<ToastNotice toast={toast} />
896894
{confirmDialog}
897895
</>
898896
</StateShell>

frontend/src/pages/Accounts.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import PageHeader from "../components/PageHeader";
66
import Pagination from "../components/Pagination";
77
import StateShell from "../components/StateShell";
88
import StatusBadge from "../components/StatusBadge";
9-
import ToastNotice from "../components/ToastNotice";
109
import { useDataLoader, type LoadOptions } from "../hooks/useDataLoader";
1110
import { useConfirmDialog } from "../hooks/useConfirmDialog";
1211
import {
@@ -1734,7 +1733,7 @@ export default function Accounts() {
17341733
failed: result.failed,
17351734
}),
17361735
);
1737-
void reload();
1736+
void reloadSilently();
17381737
} catch (error) {
17391738
showToast(
17401739
t("accounts.batchTestFailed", { error: getErrorMessage(error) }),
@@ -4786,8 +4785,6 @@ export default function Accounts() {
47864785
</Modal>
47874786

47884787
{confirmDialog}
4789-
4790-
<ToastNotice toast={toast} />
47914788
</>
47924789
</StateShell>
47934790
</div>

frontend/src/pages/Docs.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import {
1212
Server,
1313
} from "lucide-react";
1414
import { api, getAdminKey } from "../api";
15-
import ToastNotice from "../components/ToastNotice";
1615
import { Card, CardContent } from "@/components/ui/card";
1716
import { Badge } from "@/components/ui/badge";
1817
import { Button } from "@/components/ui/button";
@@ -994,7 +993,6 @@ set CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1`;
994993
</div>
995994
</div>
996995

997-
<ToastNotice toast={toast} />
998996

999997
<div className="xl:hidden mb-3 -mx-2 overflow-x-auto px-2">
1000998
<div className="flex gap-1.5 pb-1">

frontend/src/pages/ImageStudio.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { NavLink, useNavigate, useParams } from 'react-router-dom'
44
import { useTranslation } from 'react-i18next'
55
import { api } from '../api'
66
import PageHeader from '../components/PageHeader'
7-
import ToastNotice from '../components/ToastNotice'
87
import { useConfirmDialog } from '../hooks/useConfirmDialog'
98
import { useToast } from '../hooks/useToast'
109
import { formatBeijingTime } from '../utils/time'
@@ -1483,7 +1482,6 @@ export default function ImageStudio() {
14831482
{activeView === 'studio' && <ImageNoticeCarousel />}
14841483
</div>
14851484
<ImageStudioTabs activeView={activeView} />
1486-
<ToastNotice toast={toast} />
14871485
{confirmDialog}
14881486

14891487
{activeView === 'studio' && (

frontend/src/pages/OperationsErrors.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@ import OpsTabs from '../components/OpsTabs'
1818
import PageHeader from '../components/PageHeader'
1919
import Pagination from '../components/Pagination'
2020
import StateShell from '../components/StateShell'
21-
import ToastNotice from '../components/ToastNotice'
2221
import { useDataLoader } from '../hooks/useDataLoader'
2322
import { useToast } from '../hooks/useToast'
2423
import { DEFAULT_PAGE_SIZE_OPTIONS, usePersistedPageSize } from '../hooks/usePersistedPageSize'
@@ -568,7 +567,6 @@ export default function OperationsErrors() {
568567
</DialogContent>
569568
) : null}
570569
</Dialog>
571-
<ToastNotice toast={toast} />
572570
</>
573571
</StateShell>
574572
)

frontend/src/pages/PromptFilter.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import { api } from '../api'
77
import PageHeader from '../components/PageHeader'
88
import Pagination from '../components/Pagination'
99
import StateShell from '../components/StateShell'
10-
import ToastNotice from '../components/ToastNotice'
1110
import { DEFAULT_PAGE_SIZE_OPTIONS, usePersistedPageSize } from '../hooks/usePersistedPageSize'
1211
import { useDataLoader } from '../hooks/useDataLoader'
1312
import { useToast } from '../hooks/useToast'
@@ -325,7 +324,6 @@ export default function PromptFilter() {
325324
/>
326325
) : null}
327326

328-
<ToastNotice toast={toast} />
329327
</>
330328
</StateShell>
331329
)

frontend/src/pages/Proxies.tsx

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import { Button } from "@/components/ui/button";
2020
import { Input } from "@/components/ui/input";
2121
import { api, type ProxyRow, type ProxyTestResult } from "../api";
2222
import Modal from "../components/Modal";
23-
import ToastNotice from "../components/ToastNotice";
2423
import { useToast } from "../hooks/useToast";
2524
import { getErrorMessage } from "../utils/error";
2625

@@ -826,7 +825,6 @@ export default function Proxies() {
826825
</div>
827826
</Modal>
828827

829-
<ToastNotice toast={toast} />
830828
</div>
831829
);
832830
}

0 commit comments

Comments
 (0)