diff --git a/.changeset/fn-6458-cli-banner-actions.md b/.changeset/fn-6458-cli-banner-actions.md new file mode 100644 index 0000000000..589e4b1a2d --- /dev/null +++ b/.changeset/fn-6458-cli-banner-actions.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Wire dashboard CLI session banner actions so needs-attention sessions surface, supported actions call existing routes/settings flows, and unsupported actions render disabled instead of silently doing nothing. diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 7f99dbcc92..f354f583c0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -22,7 +22,7 @@ import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorP import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader"; import { TopProgressBar } from "./components/TopProgressBar"; import { ExecutorStatusBar } from "./components/ExecutorStatusBar"; -import { SessionNotificationBanner } from "./components/SessionNotificationBanner"; +import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner"; import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner"; import { SetupWarningBanner } from "./components/SetupWarningBanner"; import { CapacityRiskBanner } from "./components/CapacityRiskBanner"; @@ -246,6 +246,77 @@ export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectC return projectsLoading && projectCount === 0; } +export function isSessionNeedingInputForBanner(session: AiSessionSummary): boolean { + return ( + session.status === "awaiting_input" || + session.status === "error" || + session.status === "waiting_on_input" || + session.status === "needs_attention" + ); +} + +export function getCliActionDisabledReasonForBanner(session: AiSessionSummary, action: CliActionId): string | null { + if (action === "advance" && !session.cliSessionId) { + return "CLI session id is missing."; + } + if (action === "relaunch") { + return "Relaunch is not supported by the dashboard yet."; + } + return null; +} + +interface CliActionDeps { + currentProjectId?: string; + retryTask: (id: string) => Promise; + moveTask: (id: string, column: "todo") => Promise; + openAuthenticationSettings: () => void; + addToast: (message: string, type: "error") => void; + apiClient?: typeof api; +} + +export async function executeCliSessionBannerAction( + session: AiSessionSummary, + action: CliActionId, + deps: CliActionDeps, +): Promise { + try { + /* + * FNXC:SessionBanner 2026-06-14-19:32: + * CLI banner verbs must either call an existing dashboard route/flow or be disabled by the banner. `advance` confirms the CLI session, `retry` and `cancel` reuse task operations keyed by the session id until summaries expose a distinct task id, and `reauthenticate` opens the existing authentication settings flow. + */ + if (action === "advance") { + if (!session.cliSessionId) { + throw new Error("CLI session id is required to advance this session."); + } + await (deps.apiClient ?? api)(`/cli-sessions/${encodeURIComponent(session.cliSessionId)}/confirm-advance`, { + method: "POST", + body: JSON.stringify({ decision: "advance", ...(deps.currentProjectId ? { projectId: deps.currentProjectId } : {}) }), + }); + return; + } + + if (action === "retry") { + await deps.retryTask(session.id); + return; + } + + if (action === "cancel") { + await deps.moveTask(session.id, "todo"); + return; + } + + if (action === "reauthenticate") { + deps.openAuthenticationSettings(); + return; + } + + throw new Error("This CLI action is not supported yet."); + } catch (err) { + const message = err instanceof Error ? err.message : "CLI action failed"; + deps.addToast(message, "error"); + } +} + function AppInner() { const { t } = useTranslation("app"); const { toasts, addToast, removeToast } = useToast(); @@ -372,9 +443,11 @@ function AppInner() { // Background AI sessions - required before useModalManager const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id); - const sessionsNeedingInput = bgSessions.filter( - (session) => session.status === "awaiting_input" || session.status === "error" - ); + /* + * FNXC:SessionBanner 2026-06-14-19:32: + * CLI agent sessions use `waiting_on_input` and `needs_attention` to represent user-actionable states. The banner feed must include those statuses in addition to the legacy planning-session statuses so visible CLI actions cannot be silently hidden from users. + */ + const sessionsNeedingInput = bgSessions.filter(isSessionNeedingInputForBanner); const sessionBannersHidden = useSessionBannersHidden(); // Modal state/handlers - required before useViewState @@ -1347,6 +1420,18 @@ function AppInner() { // intentional no-op }, []); + const handleCliAction = useCallback( + (session: AiSessionSummary, action: CliActionId) => + executeCliSessionBannerAction(session, action, { + currentProjectId: currentProject?.id, + retryTask, + moveTask, + openAuthenticationSettings: () => modalManager.openSettings("authentication" as SectionId), + addToast, + }), + [addToast, currentProject?.id, modalManager, moveTask, retryTask], + ); + const [shellOnboardingComplete, setShellOnboardingComplete] = useState(false); const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false); const [shellConnectionStatus, setShellConnectionStatus] = useState(null); @@ -1948,6 +2033,8 @@ function AppInner() { onResumeSession={handleOpenBackgroundSession} onDismissSession={handleDismissNeedingInputSession} onDismissAll={handleDismissAllNeedingInputSessions} + onCliAction={handleCliAction} + getCliActionDisabledReason={getCliActionDisabledReasonForBanner} /> )} {viewMode === "project" && currentProject && ( diff --git a/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx new file mode 100644 index 0000000000..4e768e3412 --- /dev/null +++ b/packages/dashboard/app/__tests__/app-cli-action-wiring.test.tsx @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from "vitest"; +import type { AiSessionSummary } from "../api"; +import { + executeCliSessionBannerAction, + getCliActionDisabledReasonForBanner, + isSessionNeedingInputForBanner, +} from "../App"; +import type { CliActionId } from "../components/SessionNotificationBanner"; + +function cliSession(overrides: Partial = {}): AiSessionSummary { + return { + id: overrides.id ?? "FN-6458", + type: "cli-agent", + status: overrides.status ?? "needs_attention", + title: overrides.title ?? "CLI session needs attention", + projectId: overrides.projectId ?? "proj-1", + lockedByTab: overrides.lockedByTab ?? null, + updatedAt: overrides.updatedAt ?? "2026-06-14T19:32:00.000Z", + cliVariant: overrides.cliVariant ?? "userExited", + cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId") + ? overrides.cliSessionId + : "cli-session-1", + }; +} + +describe("App CLI session banner wiring", () => { + it("surfaces cli-agent needs_attention and waiting_on_input sessions through the App banner filter", () => { + expect(isSessionNeedingInputForBanner(cliSession({ status: "needs_attention" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "waiting_on_input" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "awaiting_input" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "error" }))).toBe(true); + expect(isSessionNeedingInputForBanner(cliSession({ status: "generating" }))).toBe(false); + expect(isSessionNeedingInputForBanner(cliSession({ status: "complete" }))).toBe(false); + }); + + it.each([ + ["advance", "api"], + ["retry", "retryTask"], + ["cancel", "moveTask"], + ["reauthenticate", "openSettings"], + ] as const)("maps %s to an observable existing route or flow", async (action, expected) => { + const apiClient = vi.fn().mockResolvedValue({ ok: true }); + const retryTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); + const moveTask = vi.fn().mockResolvedValue({ id: "FN-6458" }); + const openAuthenticationSettings = vi.fn(); + const addToast = vi.fn(); + + await executeCliSessionBannerAction(cliSession(), action, { + currentProjectId: "proj-1", + retryTask, + moveTask, + openAuthenticationSettings, + addToast, + apiClient, + }); + + if (expected === "api") { + expect(apiClient).toHaveBeenCalledWith( + "/cli-sessions/cli-session-1/confirm-advance", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ decision: "advance", projectId: "proj-1" }), + }), + ); + } else if (expected === "retryTask") { + expect(retryTask).toHaveBeenCalledWith("FN-6458"); + } else if (expected === "moveTask") { + expect(moveTask).toHaveBeenCalledWith("FN-6458", "todo"); + } else { + expect(openAuthenticationSettings).toHaveBeenCalledTimes(1); + } + expect(addToast).not.toHaveBeenCalled(); + }); + + it("marks unsupported or missing-id actions disabled so visible buttons are not silent no-ops", () => { + const actions: CliActionId[] = ["advance", "retry", "cancel", "reauthenticate", "relaunch"]; + const missingId = cliSession({ cliSessionId: undefined }); + const withId = cliSession(); + + const disabled = new Map(actions.map((action) => [action, getCliActionDisabledReasonForBanner(withId, action)])); + expect(disabled.get("relaunch")).toMatch(/not supported/i); + expect(disabled.get("advance")).toBeNull(); + expect(getCliActionDisabledReasonForBanner(missingId, "advance")).toMatch(/missing/i); + }); + + it("toasts instead of silently failing if an enabled CLI action route rejects", async () => { + const addToast = vi.fn(); + await executeCliSessionBannerAction(cliSession(), "retry", { + retryTask: vi.fn().mockRejectedValue(new Error("retry failed")), + moveTask: vi.fn(), + openAuthenticationSettings: vi.fn(), + addToast, + apiClient: vi.fn(), + }); + + expect(addToast).toHaveBeenCalledWith("retry failed", "error"); + }); +}); diff --git a/packages/dashboard/app/components/SessionNotificationBanner.css b/packages/dashboard/app/components/SessionNotificationBanner.css index 02dc4581aa..deb484708e 100644 --- a/packages/dashboard/app/components/SessionNotificationBanner.css +++ b/packages/dashboard/app/components/SessionNotificationBanner.css @@ -162,17 +162,32 @@ transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast); } -.session-notification-banner__resume:hover { +.session-notification-banner__resume:hover:not(:disabled) { background: color-mix(in srgb, var(--triage) 14%, transparent); border-color: var(--triage); } +.session-notification-banner__resume:disabled, +.session-notification-banner__resume--disabled { + border-color: var(--border); + color: var(--text-muted); + background: var(--surface-muted); + cursor: not-allowed; +} + .session-notification-banner__item--error .session-notification-banner__resume { border-color: color-mix(in srgb, var(--color-error) 60%, var(--border)); color: var(--color-error); } -.session-notification-banner__item--error .session-notification-banner__resume:hover { +.session-notification-banner__item--error .session-notification-banner__resume:disabled, +.session-notification-banner__item--error .session-notification-banner__resume--disabled { + border-color: var(--border); + color: var(--text-muted); + background: var(--surface-muted); +} + +.session-notification-banner__item--error .session-notification-banner__resume:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error) 14%, transparent); border-color: var(--color-error); } diff --git a/packages/dashboard/app/components/SessionNotificationBanner.tsx b/packages/dashboard/app/components/SessionNotificationBanner.tsx index 8943419b3a..efaddf85b9 100644 --- a/packages/dashboard/app/components/SessionNotificationBanner.tsx +++ b/packages/dashboard/app/components/SessionNotificationBanner.tsx @@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next"; import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react"; import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api"; -type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch"; +export type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch"; interface SessionNotificationBannerProps { sessions: AiSessionSummary[]; @@ -13,11 +13,11 @@ interface SessionNotificationBannerProps { onDismissAll: () => void; /** * CLI agent needs-attention / confirm-advance actions (CLI Agent Executor, - * U11). `advance` wires the userExited "Advance" verb + generic-tier - * confirm-advance; the others map to existing endpoints where present, else - * are no-op callbacks marked TODO-wire by the caller. + * U11). Every enabled CLI action must have an observable effect in the host; + * unsupported actions should be returned from `getCliActionDisabledReason`. */ onCliAction?: (session: AiSessionSummary, action: CliActionId) => void; + getCliActionDisabledReason?: (session: AiSessionSummary, action: CliActionId) => string | null | undefined; } // `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for @@ -139,6 +139,7 @@ export function SessionNotificationBanner({ onDismissSession, onDismissAll, onCliAction, + getCliActionDisabledReason, }: SessionNotificationBannerProps) { const { t } = useTranslation("app"); const [dismissRevision, setDismissRevision] = useState(0); @@ -300,24 +301,39 @@ export function SessionNotificationBanner({
- {variantSpec.actions.map((action) => ( - - ))} + {variantSpec.actions.map((action) => { + const label = t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal); + const disabledReason = !onCliAction + ? t("sessionBanner.cli.actionUnavailable", "Action unavailable") + : getCliActionDisabledReason?.(session, action); + const disabled = Boolean(disabledReason); + + return ( + + ); + })}