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:
5
.changeset/fn-6458-cli-banner-actions.md
Normal file
5
.changeset/fn-6458-cli-banner-actions.md
Normal 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.
|
||||||
@@ -22,7 +22,7 @@ import { BackendConnectionErrorPage } from "./components/BackendConnectionErrorP
|
|||||||
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
import { DashboardLoader, type DashboardLoaderStage } from "./components/DashboardLoader";
|
||||||
import { TopProgressBar } from "./components/TopProgressBar";
|
import { TopProgressBar } from "./components/TopProgressBar";
|
||||||
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
import { ExecutorStatusBar } from "./components/ExecutorStatusBar";
|
||||||
import { SessionNotificationBanner } from "./components/SessionNotificationBanner";
|
import { SessionNotificationBanner, type CliActionId } from "./components/SessionNotificationBanner";
|
||||||
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
import { CliBinaryInstallBanner } from "./components/CliBinaryInstallBanner";
|
||||||
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
import { SetupWarningBanner } from "./components/SetupWarningBanner";
|
||||||
import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
|
import { CapacityRiskBanner } from "./components/CapacityRiskBanner";
|
||||||
@@ -246,6 +246,77 @@ export function shouldShowFirstEverBootLoader(projectsLoading: boolean, projectC
|
|||||||
return projectsLoading && projectCount === 0;
|
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() {
|
function AppInner() {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const { toasts, addToast, removeToast } = useToast();
|
const { toasts, addToast, removeToast } = useToast();
|
||||||
@@ -372,9 +443,11 @@ function AppInner() {
|
|||||||
|
|
||||||
// Background AI sessions - required before useModalManager
|
// Background AI sessions - required before useModalManager
|
||||||
const { sessions: bgSessions, generating: bgGenerating, needsInput: bgNeedsInput, planningSessions: bgPlanningSessions, dismissSession: bgDismiss } = useBackgroundSessions(currentProject?.id);
|
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();
|
const sessionBannersHidden = useSessionBannersHidden();
|
||||||
|
|
||||||
// Modal state/handlers - required before useViewState
|
// Modal state/handlers - required before useViewState
|
||||||
@@ -1347,6 +1420,18 @@ function AppInner() {
|
|||||||
// intentional no-op
|
// 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 [shellOnboardingComplete, setShellOnboardingComplete] = useState(false);
|
||||||
const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false);
|
const [shellConnectionManagerOpen, setShellConnectionManagerOpen] = useState(false);
|
||||||
const [shellConnectionStatus, setShellConnectionStatus] = useState<ShellConnectionNativeResult | null>(null);
|
const [shellConnectionStatus, setShellConnectionStatus] = useState<ShellConnectionNativeResult | null>(null);
|
||||||
@@ -1948,6 +2033,8 @@ function AppInner() {
|
|||||||
onResumeSession={handleOpenBackgroundSession}
|
onResumeSession={handleOpenBackgroundSession}
|
||||||
onDismissSession={handleDismissNeedingInputSession}
|
onDismissSession={handleDismissNeedingInputSession}
|
||||||
onDismissAll={handleDismissAllNeedingInputSessions}
|
onDismissAll={handleDismissAllNeedingInputSessions}
|
||||||
|
onCliAction={handleCliAction}
|
||||||
|
getCliActionDisabledReason={getCliActionDisabledReasonForBanner}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewMode === "project" && currentProject && (
|
{viewMode === "project" && currentProject && (
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -162,17 +162,32 @@
|
|||||||
transition: background var(--transition-fast), color var(--transition-fast), border-color var(--transition-fast);
|
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);
|
background: color-mix(in srgb, var(--triage) 14%, transparent);
|
||||||
border-color: var(--triage);
|
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 {
|
.session-notification-banner__item--error .session-notification-banner__resume {
|
||||||
border-color: color-mix(in srgb, var(--color-error) 60%, var(--border));
|
border-color: color-mix(in srgb, var(--color-error) 60%, var(--border));
|
||||||
color: var(--color-error);
|
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);
|
background: color-mix(in srgb, var(--color-error) 14%, transparent);
|
||||||
border-color: var(--color-error);
|
border-color: var(--color-error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
|
import { AlertCircle, Lightbulb, Layers, Target, Terminal, X } from "lucide-react";
|
||||||
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
|
import type { AiSessionSummary, CliNeedsAttentionVariant } from "../api";
|
||||||
|
|
||||||
type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
|
export type CliActionId = "advance" | "retry" | "cancel" | "reauthenticate" | "relaunch";
|
||||||
|
|
||||||
interface SessionNotificationBannerProps {
|
interface SessionNotificationBannerProps {
|
||||||
sessions: AiSessionSummary[];
|
sessions: AiSessionSummary[];
|
||||||
@@ -13,11 +13,11 @@ interface SessionNotificationBannerProps {
|
|||||||
onDismissAll: () => void;
|
onDismissAll: () => void;
|
||||||
/**
|
/**
|
||||||
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
|
* CLI agent needs-attention / confirm-advance actions (CLI Agent Executor,
|
||||||
* U11). `advance` wires the userExited "Advance" verb + generic-tier
|
* U11). Every enabled CLI action must have an observable effect in the host;
|
||||||
* confirm-advance; the others map to existing endpoints where present, else
|
* unsupported actions should be returned from `getCliActionDisabledReason`.
|
||||||
* are no-op callbacks marked TODO-wire by the caller.
|
|
||||||
*/
|
*/
|
||||||
onCliAction?: (session: AiSessionSummary, action: CliActionId) => void;
|
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
|
// `cli-agent` extends the previously-closed union: a SINGLE Terminal icon for
|
||||||
@@ -139,6 +139,7 @@ export function SessionNotificationBanner({
|
|||||||
onDismissSession,
|
onDismissSession,
|
||||||
onDismissAll,
|
onDismissAll,
|
||||||
onCliAction,
|
onCliAction,
|
||||||
|
getCliActionDisabledReason,
|
||||||
}: SessionNotificationBannerProps) {
|
}: SessionNotificationBannerProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
const [dismissRevision, setDismissRevision] = useState(0);
|
const [dismissRevision, setDismissRevision] = useState(0);
|
||||||
@@ -300,14 +301,28 @@ export function SessionNotificationBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="session-notification-banner__actions">
|
<div className="session-notification-banner__actions">
|
||||||
{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 (
|
||||||
<button
|
<button
|
||||||
key={action}
|
key={action}
|
||||||
className="session-notification-banner__resume"
|
className={`session-notification-banner__resume${disabled ? " session-notification-banner__resume--disabled" : ""}`}
|
||||||
data-cli-action={action}
|
data-cli-action={action}
|
||||||
|
data-cli-action-disabled={disabled ? "true" : undefined}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={disabled ? `${label} unavailable: ${disabledReason}` : undefined}
|
||||||
|
title={disabledReason ?? undefined}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
// "advance" wires confirm-advance; other verbs hit
|
if (disabled) return;
|
||||||
// existing endpoints or remain TODO-wire no-ops upstream.
|
/*
|
||||||
|
* 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);
|
onCliAction?.(session, action);
|
||||||
if (action === "cancel" || action === "advance") {
|
if (action === "cancel" || action === "advance") {
|
||||||
dismissLocally(session);
|
dismissLocally(session);
|
||||||
@@ -315,9 +330,10 @@ export function SessionNotificationBanner({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t(CLI_ACTION_LABELS[action].key, CLI_ACTION_LABELS[action].defaultVal)}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
<button
|
<button
|
||||||
className="session-notification-banner__dismiss"
|
className="session-notification-banner__dismiss"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ function buildCliSession(overrides: Partial<AiSessionSummary>): AiSessionSummary
|
|||||||
lockedByTab: null,
|
lockedByTab: null,
|
||||||
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
updatedAt: overrides.updatedAt ?? new Date().toISOString(),
|
||||||
cliVariant: overrides.cliVariant,
|
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("Relaunch fresh")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Cancel task")).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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ describe("useBackgroundSessions", () => {
|
|||||||
mockFetchAiSessions.mockResolvedValueOnce([
|
mockFetchAiSessions.mockResolvedValueOnce([
|
||||||
makeSession({ id: "s-generating", status: "generating" }),
|
makeSession({ id: "s-generating", status: "generating" }),
|
||||||
makeSession({ id: "s-awaiting", status: "awaiting_input" }),
|
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-complete", status: "complete" }),
|
||||||
makeSession({ id: "s-error", status: "error" }),
|
makeSession({ id: "s-error", status: "error" }),
|
||||||
makeSession({ id: "s-ignored", status: "paused" as any }),
|
makeSession({ id: "s-ignored", status: "paused" as any }),
|
||||||
@@ -76,7 +78,10 @@ describe("useBackgroundSessions", () => {
|
|||||||
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
|
expect(result.current.sessions.map((session) => session.id).sort()).toEqual([
|
||||||
"s-awaiting",
|
"s-awaiting",
|
||||||
"s-generating",
|
"s-generating",
|
||||||
|
"s-needs-attention",
|
||||||
|
"s-waiting",
|
||||||
]);
|
]);
|
||||||
|
expect(result.current.needsInput).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,16 @@ function parseTimestamp(updatedAt: string | undefined): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function shouldIncludeSession(session: AiSessionSummary): boolean {
|
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(
|
function isTerminalStatus(
|
||||||
@@ -388,7 +397,9 @@ export function useBackgroundSessions(projectId?: string): UseBackgroundSessions
|
|||||||
return {
|
return {
|
||||||
sessions: active,
|
sessions: active,
|
||||||
generating: active.filter((session) => session.status === "generating").length,
|
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,
|
planningSessions,
|
||||||
dismissSession,
|
dismissSession,
|
||||||
refresh,
|
refresh,
|
||||||
|
|||||||
Reference in New Issue
Block a user