diff --git a/core/http/react-ui/public/locales/de/common.json b/core/http/react-ui/public/locales/de/common.json index 8a7bea3da..0fe553c9f 100644 --- a/core/http/react-ui/public/locales/de/common.json +++ b/core/http/react-ui/public/locales/de/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Discard unsaved changes?", + "message": "You have unsaved changes that will be lost if you leave this page.", + "leave": "Leave" + }, "actions": { "save": "Speichern", "saving": "Speichern...", diff --git a/core/http/react-ui/public/locales/en/common.json b/core/http/react-ui/public/locales/en/common.json index 7767ebbae..0086aea81 100644 --- a/core/http/react-ui/public/locales/en/common.json +++ b/core/http/react-ui/public/locales/en/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Discard unsaved changes?", + "message": "You have unsaved changes that will be lost if you leave this page.", + "leave": "Leave" + }, "actions": { "save": "Save", "saving": "Saving...", diff --git a/core/http/react-ui/public/locales/es/common.json b/core/http/react-ui/public/locales/es/common.json index 97d8fd62f..f362c2a1a 100644 --- a/core/http/react-ui/public/locales/es/common.json +++ b/core/http/react-ui/public/locales/es/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Discard unsaved changes?", + "message": "You have unsaved changes that will be lost if you leave this page.", + "leave": "Leave" + }, "actions": { "save": "Guardar", "saving": "Guardando...", diff --git a/core/http/react-ui/public/locales/id/common.json b/core/http/react-ui/public/locales/id/common.json index 80dc59c70..711b056df 100644 --- a/core/http/react-ui/public/locales/id/common.json +++ b/core/http/react-ui/public/locales/id/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Discard unsaved changes?", + "message": "You have unsaved changes that will be lost if you leave this page.", + "leave": "Leave" + }, "actions": { "save": "Simpan", "saving": "Menyimpan...", diff --git a/core/http/react-ui/public/locales/it/common.json b/core/http/react-ui/public/locales/it/common.json index 3b1ced411..ae912a3f1 100644 --- a/core/http/react-ui/public/locales/it/common.json +++ b/core/http/react-ui/public/locales/it/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Scartare le modifiche non salvate?", + "message": "Hai modifiche non salvate che andranno perse se esci da questa pagina.", + "leave": "Esci" + }, "actions": { "save": "Salva", "saving": "Salvataggio...", diff --git a/core/http/react-ui/public/locales/ko/common.json b/core/http/react-ui/public/locales/ko/common.json index e781c986a..3b5c7eed8 100644 --- a/core/http/react-ui/public/locales/ko/common.json +++ b/core/http/react-ui/public/locales/ko/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Discard unsaved changes?", + "message": "You have unsaved changes that will be lost if you leave this page.", + "leave": "Leave" + }, "actions": { "save": "저장", "saving": "저장 중...", diff --git a/core/http/react-ui/public/locales/zh-CN/common.json b/core/http/react-ui/public/locales/zh-CN/common.json index 8d5860d85..06578ad87 100644 --- a/core/http/react-ui/public/locales/zh-CN/common.json +++ b/core/http/react-ui/public/locales/zh-CN/common.json @@ -1,4 +1,9 @@ { + "unsaved": { + "title": "Discard unsaved changes?", + "message": "You have unsaved changes that will be lost if you leave this page.", + "leave": "Leave" + }, "actions": { "save": "保存", "saving": "保存中...", diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 735afe360..81d225080 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -2427,6 +2427,40 @@ select.input { border-radius: var(--radius-lg); } +/* ResponsiveTable: stack dense tables into label/value cards on narrow screens + instead of a sideways scroll. Labels come from data-label (mirrored from the + by the ResponsiveTable component). */ +@media (max-width: 640px) { + /* Direct-child selectors only: a nested table inside a cell renders normally. + min-width override defeats any inline min-width set for the desktop layout. */ + .table--responsive { border: none; min-width: 0 !important; } + .table--responsive > thead { display: none; } + .table--responsive > tbody > tr { + display: block; + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-md); + background: var(--color-surface-raised); + margin: var(--spacing-sm); + padding: var(--spacing-xs) var(--spacing-sm); + } + .table--responsive > tbody > tr > td { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-md); + border: none; + padding: var(--spacing-xs) 0; + text-align: right; + } + .table--responsive > tbody > tr > td[data-label]::before { + content: attr(data-label); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-align: left; + margin-right: auto; + } +} + .table { width: 100%; border-collapse: collapse; diff --git a/core/http/react-ui/src/components/ResponsiveTable.jsx b/core/http/react-ui/src/components/ResponsiveTable.jsx new file mode 100644 index 000000000..50b194a7f --- /dev/null +++ b/core/http/react-ui/src/components/ResponsiveTable.jsx @@ -0,0 +1,40 @@ +import { useRef, useEffect } from 'react' + +// Wraps a standard .table and makes it reflow into stacked label/value cards on +// narrow screens. Column labels are derived from the and mirrored onto +// each body cell via data-label (read by CSS ::before in the mobile layout), so +// any table becomes responsive without hand-labelling every . +export default function ResponsiveTable({ children, className = '', style, containerStyle }) { + const ref = useRef(null) + + useEffect(() => { + const table = ref.current + if (!table) return + const apply = () => { + // Direct children only, so a nested table inside a cell is left alone. + const heads = [...table.querySelectorAll(':scope > thead > tr > th')].map(th => th.textContent.trim()) + table.querySelectorAll(':scope > tbody > tr').forEach(tr => { + const cells = [...tr.children] + // Skip detail/expansion rows (a single cell spanning the table). + if (cells.length === 1 && cells[0].colSpan > 1) return + cells.forEach((td, i) => { + if (heads[i]) td.setAttribute('data-label', heads[i]) + }) + }) + } + apply() + // Re-apply when rows change (sort, paging, live data). setAttribute touches + // attributes only, so a childList/subtree observer won't retrigger itself. + const obs = new MutationObserver(apply) + obs.observe(table, { childList: true, subtree: true }) + return () => obs.disconnect() + }, []) + + return ( +
+ + {children} +
+
+ ) +} diff --git a/core/http/react-ui/src/components/UnsavedChangesGuard.jsx b/core/http/react-ui/src/components/UnsavedChangesGuard.jsx new file mode 100644 index 000000000..cda5ab1d7 --- /dev/null +++ b/core/http/react-ui/src/components/UnsavedChangesGuard.jsx @@ -0,0 +1,36 @@ +import { useEffect, useCallback } from 'react' +import { useBlocker } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import ConfirmDialog from './ConfirmDialog' + +// Guards against losing unsaved work: blocks in-app route changes (via the +// router's useBlocker) and warns on tab close/reload (beforeunload) whenever +// `when` is true. Drop into any page that has a dirty-state signal. +export default function UnsavedChangesGuard({ when }) { + const { t } = useTranslation('common') + const blocker = useBlocker( + useCallback( + ({ currentLocation, nextLocation }) => when && currentLocation.pathname !== nextLocation.pathname, + [when] + ) + ) + + useEffect(() => { + if (!when) return + const handler = (e) => { e.preventDefault(); e.returnValue = '' } + window.addEventListener('beforeunload', handler) + return () => window.removeEventListener('beforeunload', handler) + }, [when]) + + return ( + blocker.proceed?.()} + onCancel={() => blocker.reset?.()} + /> + ) +} diff --git a/core/http/react-ui/src/pages/AgentCreate.jsx b/core/http/react-ui/src/pages/AgentCreate.jsx index 825a590ca..533b45e0f 100644 --- a/core/http/react-ui/src/pages/AgentCreate.jsx +++ b/core/http/react-ui/src/pages/AgentCreate.jsx @@ -1,8 +1,9 @@ -import { useState, useEffect, useMemo } from 'react' +import { useState, useEffect, useMemo, useRef } from 'react' import { useParams, useNavigate, useLocation, useOutletContext, useSearchParams } from 'react-router-dom' import { agentsApi, skillsApi } from '../utils/api' import SearchableModelSelect from '../components/SearchableModelSelect' import PageHeader from '../components/PageHeader' +import UnsavedChangesGuard from '../components/UnsavedChangesGuard' import { CAP_CHAT, CAP_TRANSCRIPT, CAP_TTS } from '../utils/capabilities' import Toggle from '../components/Toggle' import SettingRow from '../components/SettingRow' @@ -296,6 +297,8 @@ export default function AgentCreate() { const [activeSection, setActiveSection] = useState('BasicInfo') const [meta, setMeta] = useState(null) const [form, setForm] = useState({}) + // Snapshot of the form as first loaded, for the unsaved-changes guard. + const initialFormRef = useRef(null) const [connectors, setConnectors] = useState([]) const [actions, setActions] = useState([]) const [filters, setFilters] = useState([]) @@ -374,6 +377,7 @@ export default function AgentCreate() { if (Array.isArray(sourceConfig.selected_skills)) setSelectedSkills(sourceConfig.selected_skills) } + initialFormRef.current = initialForm setForm(initialForm) } catch (err) { addToast(`Failed to load configuration: ${err.message}`, 'error') @@ -819,8 +823,12 @@ export default function AgentCreate() { ) } + const dirty = initialFormRef.current != null && + JSON.stringify(form) !== JSON.stringify(initialFormRef.current) + return (
+