FN-6458: wire CLI session banner actions

Wire the dashboard session banner to execute supported CLI session actions and disable unsupported ones.

- Route advance, retry, cancel, and reauthenticate banner actions through existing dashboard API/task/settings flows.
- Include CLI waiting and needs-attention sessions in background session filtering for banner visibility.
- Disable unavailable CLI banner actions with accessible labels and disabled styling.
- Cover action wiring, disabled states, and CLI session inclusion with dashboard tests.

Files changed:
 .changeset/fn-6458-cli-banner-actions.md           |  5 ++
 packages/dashboard/app/App.tsx                     | 95 ++++++++++++++++++++-
 .../app/__tests__/app-cli-action-wiring.test.tsx   | 98 ++++++++++++++++++++++
 .../app/components/SessionNotificationBanner.css   | 19 ++++-
 .../app/components/SessionNotificationBanner.tsx   | 60 ++++++++-----
 .../__tests__/SessionNotificationBanner.test.tsx   | 81 +++++++++++++++++-
 .../hooks/__tests__/useBackgroundSessions.test.ts  |  5 ++
 .../dashboard/app/hooks/useBackgroundSessions.ts   | 15 +++-
 8 files changed, 347 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-6458

Fusion-Task-Lineage: 5f6c57a3-07e2-4353-89e3-c7de20984813
This commit is contained in:
gsxdsm
2026-06-14 20:01:57 -07:00
parent a26d79cbb6
commit cd2da104f9
8 changed files with 347 additions and 31 deletions

View File

@@ -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.

View File

@@ -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<unknown>;
moveTask: (id: string, column: "todo") => Promise<unknown>;
openAuthenticationSettings: () => void;
addToast: (message: string, type: "error") => void;
apiClient?: typeof api;
}
export async function executeCliSessionBannerAction(
session: AiSessionSummary,
action: CliActionId,
deps: CliActionDeps,
): Promise<void> {
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<ShellConnectionNativeResult | null>(null);
@@ -1948,6 +2033,8 @@ function AppInner() {
onResumeSession={handleOpenBackgroundSession}
onDismissSession={handleDismissNeedingInputSession}
onDismissAll={handleDismissAllNeedingInputSessions}
onCliAction={handleCliAction}
getCliActionDisabledReason={getCliActionDisabledReasonForBanner}
/>
)}
{viewMode === "project" && currentProject && (

View File

@@ -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> = {}): 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");
});
});

View File

@@ -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);
}

View File

@@ -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({
</div>
<div className="session-notification-banner__actions">
{variantSpec.actions.map((action) => (
<button
key={action}
className="session-notification-banner__resume"
data-cli-action={action}
onClick={() => {
// "advance" wires confirm-advance; other verbs hit
// existing endpoints or remain TODO-wire no-ops upstream.
onCliAction?.(session, action);
if (action === "cancel" || action === "advance") {
dismissLocally(session);
onDismissSession(session.id);
}
}}
>
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
</button>
))}
{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 (
<button
key={action}
className={`session-notification-banner__resume${disabled ? " session-notification-banner__resume--disabled" : ""}`}
data-cli-action={action}
data-cli-action-disabled={disabled ? "true" : undefined}
disabled={disabled}
aria-label={disabled ? `${label} unavailable: ${disabledReason}` : undefined}
title={disabledReason ?? undefined}
onClick={() => {
if (disabled) return;
/*
* FNXC:SessionBanner 2026-06-14-19:32:
* Enabled CLI action buttons must call the host handler; unsupported or missing-id actions render disabled instead so no visible action can fall through to a silent no-op. Advance and cancel preserve the banner's local-dismiss contract after firing the observable host action.
*/
onCliAction?.(session, action);
if (action === "cancel" || action === "advance") {
dismissLocally(session);
onDismissSession(session.id);
}
}}
>
{label}
</button>
);
})}
<button
className="session-notification-banner__dismiss"
onClick={() => {

View File

@@ -327,7 +327,7 @@ function buildCliSession(overrides: Partial<AiSessionSummary>): AiSessionSummary
lockedByTab: null,
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
cliVariant: overrides.cliVariant,
cliSessionId: overrides.cliSessionId ?? "cli-1",
cliSessionId: Object.prototype.hasOwnProperty.call(overrides, "cliSessionId") ? overrides.cliSessionId : "cli-1",
};
}
@@ -419,4 +419,83 @@ describe("SessionNotificationBanner — cli-agent (U11)", () => {
expect(screen.getByText("Relaunch fresh")).toBeInTheDocument();
expect(screen.getByText("Cancel task")).toBeInTheDocument();
});
it.each([
["userExited", ["advance", "retry", "cancel"]],
["authFailed", ["reauthenticate", "retry"]],
["resume-exhausted", ["relaunch", "cancel"]],
] as const)("makes every %s action observable or disabled", (cliVariant, actions) => {
for (const action of actions) {
dismissedIds.clear();
const onCliAction = vi.fn();
const onDismissSession = vi.fn();
const { unmount } = render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant })]}
onResumeSession={vi.fn()}
onDismissSession={onDismissSession}
onDismissAll={vi.fn()}
onCliAction={onCliAction}
getCliActionDisabledReason={(_session, candidate) =>
candidate === "relaunch" ? "Relaunch is not supported by the dashboard yet." : null
}
/>,
);
const button = document.querySelector<HTMLButtonElement>(`[data-cli-action="${action}"]`);
expect(button).toBeTruthy();
if (button?.disabled) {
expect(button).toHaveAccessibleName(/unavailable:/i);
expect(onCliAction).not.toHaveBeenCalled();
} else {
fireEvent.click(button!);
expect(onCliAction).toHaveBeenCalledWith(expect.objectContaining({ id: "cli-1" }), action);
if (action === "advance" || action === "cancel") {
expect(onDismissSession).toHaveBeenCalledWith("cli-1");
} else {
expect(onDismissSession).not.toHaveBeenCalled();
}
}
unmount();
}
});
it("disables CLI actions when the host provides no action handler", () => {
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "authFailed" })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
/>,
);
for (const button of screen.getAllByRole("button").filter((node) => node.hasAttribute("data-cli-action"))) {
expect(button).toBeDisabled();
expect(button).toHaveAccessibleName(/unavailable:/i);
}
});
it("disables actions that require a missing cliSessionId without leaving an empty click target", () => {
const onCliAction = vi.fn();
render(
<SessionNotificationBanner
sessions={[buildCliSession({ status: "needs_attention", cliVariant: "userExited", cliSessionId: undefined })]}
onResumeSession={vi.fn()}
onDismissSession={vi.fn()}
onDismissAll={vi.fn()}
onCliAction={onCliAction}
getCliActionDisabledReason={(session, action) =>
action === "advance" && !session.cliSessionId ? "CLI session id is missing." : null
}
/>,
);
const advance = screen.getByRole("button", { name: /advance unavailable: cli session id is missing/i });
expect(advance).toBeDisabled();
expect(advance).toHaveAttribute("data-cli-action-disabled", "true");
expect(advance).toHaveTextContent("Advance");
fireEvent.click(advance);
expect(onCliAction).not.toHaveBeenCalled();
});
});

View File

@@ -61,6 +61,8 @@ describe("useBackgroundSessions", () => {
mockFetchAiSessions.mockResolvedValueOnce([
makeSession({ id: "s-generating", status: "generating" }),
makeSession({ id: "s-awaiting", status: "awaiting_input" }),
makeSession({ id: "s-waiting", type: "cli-agent", status: "waiting_on_input" }),
makeSession({ id: "s-needs-attention", type: "cli-agent", status: "needs_attention", cliVariant: "authFailed" }),
makeSession({ id: "s-complete", status: "complete" }),
makeSession({ id: "s-error", status: "error" }),
makeSession({ id: "s-ignored", status: "paused" as any }),
@@ -76,7 +78,10 @@ describe("useBackgroundSessions", () => {
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
"s-awaiting",
"s-generating",
"s-needs-attention",
"s-waiting",
]);
expect(result.current.needsInput).toBe(2);
});
});

View File

@@ -29,7 +29,16 @@ function parseTimestamp(updatedAt: string | undefined): number {
}
function shouldIncludeSession(session: AiSessionSummary): boolean {
return session.status === "generating" || session.status === "awaiting_input";
/*
* FNXC:SessionBanner 2026-06-14-19:32:
* Background session consumers need CLI agent `waiting_on_input` and `needs_attention` rows available so App can route them to SessionNotificationBanner. Counts below remain scoped to their legacy meanings, so BackgroundTasksIndicator panels are not reclassified by this inclusion.
*/
return (
session.status === "generating" ||
session.status === "awaiting_input" ||
session.status === "waiting_on_input" ||
session.status === "needs_attention"
);
}
function isTerminalStatus(
@@ -388,7 +397,9 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
return {
sessions: active,
generating: active.filter((session) => session.status === "generating").length,
needsInput: active.filter((session) => session.status === "awaiting_input").length,
needsInput: active.filter(
(session) => session.status === "awaiting_input" || session.status === "waiting_on_input",
).length,
planningSessions,
dismissSession,
refresh,