Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,8 @@ export const de: Record<TKey, string> = {
"dash.updateStatus.running": "opencodex wird aktualisiert.",
"dash.updateStatus.restarting": "Update installiert. Proxy wird neu gestartet.",
"dash.updateStatus.succeeded": "Update abgeschlossen.",
"dash.updateVersionTransition": "{currentVersion} -> {latestVersion}.",

"dash.updateStatus.failed": "Update fehlgeschlagen.",
"prov.subtitle": "Konfiguriere die Upstream-Anbieter, die opencodex in Codex routet. Melde dich mit einem Konto an, füge einen Anbieter hinzu oder bearbeite die Rohkonfiguration.",
"prov.add": "Anbieter hinzufügen",
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,8 @@ export const en = {
"dash.updateStatus.running": "Updating opencodex.",
"dash.updateStatus.restarting": "Update installed. Restarting proxy.",
"dash.updateStatus.succeeded": "Update finished.",
"dash.updateVersionTransition": "{currentVersion} -> {latestVersion}.",

"dash.updateStatus.failed": "Update failed.",

// providers
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ export const ja: Record<TKey, string> = {
"dash.updateStatus.running": "opencodex を更新しています。",
"dash.updateStatus.restarting": "更新をインストールしました。プロキシを再起動中。",
"dash.updateStatus.succeeded": "更新が完了しました。",
"dash.updateVersionTransition": "{currentVersion} -> {latestVersion}.",

"dash.updateStatus.failed": "更新に失敗しました。",

// providers
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,8 @@ export const ko: Record<TKey, string> = {
"dash.updateStatus.running": "opencodex 업데이트 중입니다.",
"dash.updateStatus.restarting": "업데이트 설치 완료. 프록시를 재시작하는 중입니다.",
"dash.updateStatus.succeeded": "업데이트가 완료됐습니다.",
"dash.updateVersionTransition": "{currentVersion} -> {latestVersion}.",

"dash.updateStatus.failed": "업데이트에 실패했습니다.",

// providers
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ export const ru: Record<TKey, string> = {
"dash.updateStatus.running": "Обновление opencodex.",
"dash.updateStatus.restarting": "Обновление установлено. Перезапуск прокси.",
"dash.updateStatus.succeeded": "Обновление завершено.",
"dash.updateVersionTransition": "{currentVersion} -> {latestVersion}.",

"dash.updateStatus.failed": "Обновление не удалось.",

// providers
Expand Down
2 changes: 2 additions & 0 deletions gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ export const zh: Record<TKey, string> = {
"dash.updateStatus.running": "正在更新 opencodex。",
"dash.updateStatus.restarting": "更新已安装。正在重启代理。",
"dash.updateStatus.succeeded": "更新完成。",
"dash.updateVersionTransition": "{currentVersion} -> {latestVersion}.",

"dash.updateStatus.failed": "更新失败。",

// providers
Expand Down
47 changes: 33 additions & 14 deletions gui/src/pages/Models.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Switch, Notice, EmptyState, Select, Tooltip } from "../ui";
import { IconChevron, IconBoxes, IconInfo, IconShuffle } from "../icons";
import { IconChevron, IconBoxes, IconInfo, IconShuffle, IconCheck, IconAlert } from "../icons";
import { useT } from "../i18n/shared";
import type { TFn, TKey } from "../i18n/shared";
import { modelLabel } from "../model-display";
Expand Down Expand Up @@ -81,6 +81,25 @@ export default function Models({ apiBase }: { apiBase: string }) {
const needsDefaultCollapseRef = useRef(initialCollapsed === null);
const [status, setStatus] = useState("");
const [ok, setOk] = useState(false);
// Feedback generation: a repeated identical message (same success string, same validation
// error) must still re-arm the toast timer. Clearing `status` alone is not enough — a
// second identical value bails out of React's state diff, so the old timer would dismiss
// the new toast early. Every publish bumps the generation.
const [feedbackGen, setFeedbackGen] = useState(0);
const publishFeedback = (nextOk: boolean, message: string) => {
setOk(nextOk);
setStatus(message);
setFeedbackGen(g => g + 1);
};
// Transient action feedback as a fixed toast: appearing or auto-clearing it never shifts
// the workspace below (the old inline Notice pushed the whole model grid down by its
// height on every apply). The timer itself just clears the status again.
useEffect(() => {
if (!status) return;
const holdMs = ok ? 6000 : 8000;
const timer = setTimeout(() => setStatus(""), holdMs);
return () => clearTimeout(timer);
}, [status, ok, feedbackGen]);
const [busy, setBusy] = useState(false);
const busyRef = useRef(false);
const loadGenerationRef = useRef(0);
Expand Down Expand Up @@ -430,7 +449,7 @@ export default function Models({ apiBase }: { apiBase: string }) {

const applyCustomCap = () => {
const value = Number(customCap.replace(/[_,\s]/g, ""));
if (!Number.isFinite(value) || value <= 0) { setOk(false); setStatus(t("models.capSaveFailed")); return; }
if (!Number.isFinite(value) || value <= 0) { publishFeedback(false, t("models.capSaveFailed")); return; }
setShowCustom(false);
setGlobalCap(value);
};
Expand Down Expand Up @@ -496,7 +515,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
// (setMaxConcurrentThreads no-ops on equal value), so a re-selected current
// value or a double click can never double-write config.toml.
if (!v2 || v2BusyRef.current) return;
if (!Number.isInteger(value) || value < 1) { setOk(false); setStatus(t("models.v2ThreadsInvalid")); return; }
if (!Number.isInteger(value) || value < 1) { publishFeedback(false, t("models.v2ThreadsInvalid")); return; }
if (v2.maxConcurrentThreadsPerSession === value) return;
setV2Busy(true);
v2BusyRef.current = true;
Expand Down Expand Up @@ -581,8 +600,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
try {
await readJsonOrThrow(r, t("models.customSaveFailed"));
setCustomModalOpen(false);
setOk(true);
setStatus(t("models.customAdded"));
publishFeedback(true, t("models.customAdded"));
await load(true);
} catch (e) {
setCustomError(e instanceof Error ? e.message : t("models.customSaveFailed"));
Expand All @@ -606,8 +624,7 @@ export default function Models({ apiBase }: { apiBase: string }) {
try {
await readJsonOrThrow(r, t("models.customSaveFailed"));
setCustomModalOpen(false);
setOk(true);
setStatus(t("models.customUpdated"));
publishFeedback(true, t("models.customUpdated"));
await load(true);
} catch (e) {
setCustomError(e instanceof Error ? e.message : t("models.customSaveFailed"));
Expand All @@ -623,16 +640,13 @@ export default function Models({ apiBase }: { apiBase: string }) {
try {
const r = await fetch(`${apiBase}/api/custom-models/${encodeURIComponent(id)}`, { method: "DELETE" });
if (r.ok) {
setOk(true);
setStatus(t("models.customDeleted"));
publishFeedback(true, t("models.customDeleted"));
await load(true);
} else {
setOk(false);
setStatus(t("models.customSaveFailed"));
publishFeedback(false, t("models.customSaveFailed"));
}
} catch {
setOk(false);
setStatus(t("models.networkError"));
publishFeedback(false, t("models.networkError"));
}
};

Expand Down Expand Up @@ -1343,7 +1357,12 @@ export default function Models({ apiBase }: { apiBase: string }) {
</div>
</div>
<p className="page-sub">{t("models.subtitle")}</p>
{status && <Notice tone={ok ? "ok" : "err"}>{status}</Notice>}
{status && (
<div className={`action-toast notice ${ok ? "notice-ok" : "notice-err"}`} role="status" aria-live="polite">
{ok ? <IconCheck /> : <IconAlert />}
<span>{status}</span>
</div>
)}
{/* Keep the last-good catalog interactive but make a failed revalidation explicit. */}
{catalogState.showError && <Notice tone="err">{t("models.loadFail")}</Notice>}
<div className="models-workspace-root" aria-busy={catalogState.refreshing || undefined}>
Expand Down
157 changes: 108 additions & 49 deletions gui/src/pages/dashboard-overview-sections.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { IconAlert, IconInfo, IconRefresh } from "../icons";
import { useEffect, useRef, useState } from "react";
import { IconAlert, IconCheck, IconInfo, IconRefresh, IconX } from "../icons";
import { Trans } from "../i18n/provider";
import { Select } from "../ui";
import { navigateHash } from "../hash-routing";
Expand Down Expand Up @@ -144,70 +145,128 @@ export function DashboardInjectionPanel({ d }: { apiBase: string; d: Dash }) {
export function DashboardMaintenancePanel({ d }: { d: Dash }) {
const {
t, runSync, syncing, updateTriggerRef, openUpdateDialog, updateLoading, updateOpen,
syncResult, syncError, updateJob, reconnecting,
syncResult, syncError, updateJob, reconnecting, clearSyncFeedback,
} = d;

// A sync result that carries actionable guidance (generic warning, native subagent
// defaults override, or the stale app-server hint) is the ONLY place that warning is
// visible, so it must not vanish on a timer: it stays until the next sync or an
// explicit dismiss.
const syncHoldsWarning = !!syncResult && (
!!syncResult.warning
|| !!syncResult.nativeSubagentDefaultsWarning
|| !!syncResult.staleAppServerHint
);

// Sync feedback is a transient fixed toast instead of an inline notice: the toast sits
// outside the layout flow, so the result can appear without pushing the panels below
// this card down by a full box height (the old notice shifted the whole dashboard on
// every sync click). Plain results auto-dismiss; a new sync clears and re-arms it.
// Dismissal is published to the dashboard data (clearSyncFeedback), not just a local
// flag, so switching tabs and back cannot resurrect a stale result as a fresh toast.
const [syncToastDismissed, setSyncToastDismissed] = useState(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep dismissed sync results from remounting as fresh toasts

Because syncResult and syncError live in useDashboardData above the dashboard tabs, this component-local dismissed state resets whenever the Overview panel unmounts. If a user runs Sync, lets the toast auto-dismiss, switches to another dashboard tab, and returns to Overview, the old result still satisfies the render condition and the stale toast appears again without a new sync; persist the dismissed/result token in dashboard data or clear the sync result when the timer fires.

Useful? React with 👍 / 👎.

const syncToastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

useEffect(() => {
if (syncToastTimerRef.current) {
clearTimeout(syncToastTimerRef.current);
syncToastTimerRef.current = null;
}
if ((syncResult || syncError) && !syncHoldsWarning) {
const holdMs = syncError ? 8000 : 6000;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep actionable sync warnings visible

When /api/sync succeeds but returns nativeSubagentDefaultsWarning or staleAppServerHint, the toast contains the only visible warning/restart instruction, yet this timer treats it like a plain success and hides it after 6 seconds. Users who miss that brief toast have no persistent way on the dashboard to recover the warning or the ocx sync --restart-codex hint; keep warning-bearing results visible until the next sync or provide a dismiss control.

Useful? React with 👍 / 👎.

syncToastTimerRef.current = setTimeout(() => {
syncToastTimerRef.current = null;
setSyncToastDismissed(true);
clearSyncFeedback();
}, holdMs);
}
return () => {
if (syncToastTimerRef.current) clearTimeout(syncToastTimerRef.current);
};
}, [syncResult, syncError, syncHoldsWarning, clearSyncFeedback]);

// A fresh click re-arms the toast even if the previous one was already auto-dismissed.
const handleRunSync = () => {
setSyncToastDismissed(false);
void runSync();
};

// Shared dismiss affordance for the sync toast: closes it locally AND clears the
// dashboard-level result so it cannot remount as fresh on the next Overview visit.
const dismissSyncToast = () => {
setSyncToastDismissed(true);
clearSyncFeedback();
};

return (
<div className="panel maintenance-panel">
{/* Same one-row chrome as Sub-agent delegation: copy left, action right. */}
<div className="dash-sync-summary">
<div className="dash-sync-copy">
<div className="font-semibold">{t("dash.syncModels")}</div>
<div className="muted text-control dash-sync-hint">{t("dash.syncModelsHint")}</div>
</div>
<div className="maintenance-actions">
<button type="button" className="btn btn-ghost btn-sm" onClick={runSync} disabled={syncing}>
<IconRefresh /> {syncing ? t("dash.syncing") : t("dash.syncRun")}
</button>
{/*
The update flow lives in the sidebar footer, which reports whether one is waiting
and is reachable from every page. A second button here duplicated it without
adding that signal. The trigger stays as a zero-size anchor so the deep link
(`#dashboard/update`) still has something to open against and the dialog has a
focus target to return to on close.
*/}
<button
ref={updateTriggerRef}
type="button"
className="maintenance-update-anchor"
onClick={openUpdateDialog}
disabled={updateLoading}
aria-haspopup="dialog"
aria-controls="dashboard-update-dialog"
aria-expanded={updateOpen}
aria-label={t("dash.checkUpdate")}
tabIndex={-1}
/>
<>
<div className="panel maintenance-panel">
{/* Same one-row chrome as Sub-agent delegation: copy left, action right. */}
<div className="dash-sync-summary">
<div className="dash-sync-copy">
<div className="font-semibold">{t("dash.syncModels")}</div>
<div className="muted text-control dash-sync-hint">{t("dash.syncModelsHint")}</div>
</div>
<div className="maintenance-actions">
<button type="button" className="btn btn-ghost btn-sm" onClick={handleRunSync} disabled={syncing}>
<IconRefresh className={syncing ? "spin-icon" : undefined} /> {syncing ? t("dash.syncing") : t("dash.syncRun")}
</button>
{/*
The update flow lives in the sidebar footer, which reports whether one is waiting
and is reachable from every page. A second button here duplicated it without
adding that signal. The trigger stays as a zero-size anchor so the deep link
(`#dashboard/update`) still has something to open against and the dialog has a
focus target to return to on close.
*/}
<button
ref={updateTriggerRef}
type="button"
className="maintenance-update-anchor"
onClick={openUpdateDialog}
disabled={updateLoading}
aria-haspopup="dialog"
aria-controls="dashboard-update-dialog"
aria-expanded={updateOpen}
aria-label={t("dash.checkUpdate")}
tabIndex={-1}
/>
</div>
</div>
{updateJob && (
<div className={`notice ${updateJob.status === "failed" ? "notice-err" : "notice-ok"} maintenance-notice`} role="status">
{updateJob.status === "failed" ? <IconAlert /> : <IconRefresh />}
<span>
{updateJobLabel(updateJob.status, t)}
{updateJob.latestVersion ? ` ${t("dash.updateVersionTransition", { currentVersion: updateJob.currentVersion, latestVersion: updateJob.latestVersion })}` : ""}
{reconnecting ? ` ${t("dash.updateReconnecting")}` : ""}
{updateJob.error ? ` ${updateJob.error}` : ""}
</span>
</div>
)}
</div>
{syncResult && (
<div className={`notice ${syncResult.nativeSubagentDefaultsWarning ? "notice-warn" : "notice-ok"} maintenance-notice`} role="status">
{syncResult.nativeSubagentDefaultsWarning ? <IconAlert /> : <IconRefresh />}
{!syncToastDismissed && syncResult && (
<div className={`action-toast notice ${syncHoldsWarning ? "notice-warn" : "notice-ok"}`} role="status" aria-live="polite">
{syncHoldsWarning ? <IconAlert /> : <IconCheck />}
<span>
{t("dash.syncOk", { count: syncResult.added })}
{syncResult.warning ? ` ${syncResult.warning}` : ""}
{syncResult.nativeSubagentDefaultsWarning ? ` ${syncResult.nativeSubagentDefaultsWarning}` : ""}
{syncResult.staleAppServerHint ? <>{" "}<Trans k="dash.syncStaleHint" cmd="ocx sync --restart-codex" /></> : null}
</span>
<button type="button" className="action-toast-dismiss" onClick={dismissSyncToast} aria-label={t("api.dismiss")}>
<IconX width={13} height={13} aria-hidden="true" />
</button>
</div>
)}
{syncError && (
<div className="notice notice-err maintenance-notice" role="status">
{!syncToastDismissed && syncError && (
<div className="action-toast notice notice-err" role="status" aria-live="polite">
<IconAlert /><span>{t("dash.syncFailed", { error: syncError })}</span>
<button type="button" className="action-toast-dismiss" onClick={dismissSyncToast} aria-label={t("api.dismiss")}>
<IconX width={13} height={13} aria-hidden="true" />
</button>
</div>
)}
{updateJob && (
<div className={`notice ${updateJob.status === "failed" ? "notice-err" : "notice-ok"} maintenance-notice`} role="status">
{updateJob.status === "failed" ? <IconAlert /> : <IconRefresh />}
<span>
{updateJobLabel(updateJob.status, t)}
{updateJob.latestVersion ? ` ${updateJob.currentVersion} -> ${updateJob.latestVersion}.` : ""}
{reconnecting ? ` ${t("dash.updateReconnecting")}` : ""}
{updateJob.error ? ` ${updateJob.error}` : ""}
</span>
</div>
)}
</div>
</>
);
}

Expand Down
13 changes: 11 additions & 2 deletions gui/src/pages/use-dashboard-data.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useKeyedClientResource } from "../client-resource";
import { replaceHash } from "../hash-routing";
import { useI18n } from "../i18n/shared";
Expand Down Expand Up @@ -589,6 +589,15 @@ export function useDashboardData(apiBase: string) {
}
};

// Clears the sync result/error in this hook. The dashboard toast owns its own dismissal
// timer but must publish the dismissal here: syncResult/syncError live above the dashboard
// tabs, so a component-local flag alone would let a stale result remount as a fresh toast
// after the Overview panel unmounts and comes back.
const clearSyncFeedback = useCallback(() => {
setSyncResult(null);
setSyncError(null);
}, []);

const runSync = async () => {
if (syncing) return;
setSyncing(true);
Expand Down Expand Up @@ -737,7 +746,7 @@ export function useDashboardData(apiBase: string) {
effortCapHelpTriggerRef, updateTriggerRef, maHelpTriggerRef, shadowCallHelpTriggerRef,
effortCapHelpDialogRef, updateDialogRef, maHelpDialogRef, shadowCallHelpDialogRef,
filteredGroups, sidecarModels,
saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, runSync,
saveSidecar, saveShadowCall, switchMaMode, toggleCodexAutoStart, runSync, clearSyncFeedback,
fetchUpdateCheck, closeUpdateDialog, openUpdateDialog, changeUpdateChannel, runUpdate,
};
}
Loading
Loading