-
Notifications
You must be signed in to change notification settings - Fork 678
feat(gui): fixed action toasts for dashboard sync + model-apply feedback #1050
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d887cd3
8a66a10
339b8a0
f9fdb19
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"; | ||
|
|
@@ -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); | ||
| 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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When 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> | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Because
syncResultandsyncErrorlive inuseDashboardDataabove 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 👍 / 👎.